canopy-security API Reference
On this page
Overview
Cross-link: canopy-security Data Model (#419)
Central audit and security service. Subscribes to ALL events via RabbitMQ wildcard (#) routing key, persists them with SHA-256 hash chain integrity, runs background breach detection, and provides APIs for audit review and compliance reporting.
- Base URL
- Authentication
-
Bearer token (Keycloak RS256 JWT)
- Minimum role
-
admin
- Swagger UI
- Database
-
canopy_security
Receiver contract (OIDC S-security, #1427 / ADR-043 §C)
canopy-security is the third service on the ADR-043 receiver contract
(canopy_auth::ReceiverContract) — user-only enforcement only. It is
not a program service, so there is no hop-2 exchanged route: audit
ingest and the signing-key registry stay genuine service-to-service
require_service_caller, and the 14 dual surfaces
(is_service() || admin) are unchanged behind the exchanged_gate
middleware. See the tanf API page for the
full bearer-shape and guard-family description.
-
require_user_only(["admin"])onPOST /v1/security/archive— the decision-10 "service tokens are rejected" posture, now mechanical. -
require_user_only(["admin", "quality_control"])onGET /v1/export/audit-events— a bulk PII egress with no service arm. -
Under
CANOPY_SECURITY__ENFORCE_USER_ONLY_ROUTES=trueonly an exchanged per-target token (aud=canopy-securityexactly, allowlistedazp) carrying the named role passes these two routes; a legacy broad-audience worker bearer is 403aud_not_exact. -
Attribution via
EffectiveUseron the archiverequested_byand the export self-auditactor(exchanged bearers preservesub; theX-Canopy-Actorheader is retired fleet-wide — #1443).
Receiver knobs are documented in the configuration reference.
Audit Events
GET /v1/security/events
List audit events with filtering and pagination.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
|
integer |
Page size (default: 50) |
|
integer |
Offset for pagination |
|
string |
Filter by originating service (e.g., |
|
string |
Filter by event type (e.g., |
|
string |
Filter by action |
|
uuid |
Filter audit events to a single household. Required by the worker portal Audit section (Plan worker-intake-program-independence MR5a/MR5b), backed by the |
|
string[] (repeated) |
Program-scope visibility filter (#1519, ADR-044). Repeated |
Response (200): Array of AuditEvent objects.
audit_events table only — rows older than the operator-set archive
age threshold have moved to audit_events_archive and do not appear here.
History contracts ride the by-id lookup (archive ∪ live), the FOIA export union
(GET /v1/export/audit-events), the fact-history endpoint, and the keyset
GET /v1/security/archive list.
GET /v1/security/events/{id}
Get a single audit event by ID. Reads archive ∪ live (#1208): the live PK probe first, else the archive twin — an archived row keeps resolving.
Response (200): AuditEvent object.
Response (404): Event not found.
GET /v1/security/persons/{person_id}/fact-history/{resource}
The scoped, transaction-time change-history of one person’s eligibility
facts (epic &56 / T1-6, ADR-027 §4). Projects the append-only audit_events
ledger to the attributed *.claimed / income.closed events canopy-persons
emits (T1-5), filtered to one person and one resource kind, ordered by the
hash-chain insert time (created_at, strictly increasing) — oldest first. One
resource returns a fact type’s full history (claims, corrections, and the
close); the entry action distinguishes them.
Path parameters:
| Parameter | Type | Description |
|---|---|---|
|
uuid |
The person whose fact history is queried (matched against the |
|
enum |
One of |
Response (200): Array of FactChangeEntry objects, each carrying
person_id, fact_id, version_id (claim only), action (claim / close),
actor_sub / actor_role (the authoring worker/applicant; null for a close —
the ADR-019 on-behalf-of limitation), claim_source, claim_status, the
opaque per-kind before (superseded windows) and after (new value; null for a
close), recorded_at (the audit row’s transaction-time), and the opaque
event_hash. Since T2-6 MR9 (#687, ADR-036) the PII money leaves inside
before/after are sealed (a SealedValue envelope — canopy-security never
holds the DEK), so this endpoint surfaces ciphertext for them and the worker UI
renders a (sealed) marker; the structural discriminators (*_type, frequency)
stay plaintext. The actual figure is read from the system-of-record (canopy-persons)
— see #920.
Response (400): Unknown resource. 403: caller is neither service-class nor admin.
Minimum role: service-class token or admin (the same house auth as the rest of the service). Worker (caseworker) scoping is enforced at the canopy-web BFF — which gates on the worker’s case access + program scope, resolves the household’s members, and fans out here per member to compose the household view — because canopy-security cannot see the worker identity until the ADR-019 plumbing lands. canopy-security stays a self-contained leaf (ADR-001): it calls no other service.
audit_events hash covers the actor
(user_id/user_role + metadata.author), action, resource, source_service,
household_id, and a content-hash of metadata (the before/after). Verification is
server-side (verify_chain recomputes each row with the single chain formula;
T2-6 #687 dropped the legacy v1 formula + the per-row hash_version); the
event_hash returned here stays an opaque integrity token — it is not
independently recomputable from this DTO (which omits previous_hash/event_id/
timestamp), so a client treats it as a correlation handle, not a recompute input.
POST /v1/security/audit/ingest
HTTP audit ingress for broker-less services (Plan 3 MR5a, ADR-026). canopy-security normally ingests audit events via the RabbitMQ wildcard subscriber, but the applicant portal (canopy-portal) is Postgres- and RabbitMQ-free, so its session security events (mint / revoke / kill-switch) post here instead. The server mints the event_id and chains the row through the same insert_audit_event path the subscriber uses (ADR-014) — an HTTP-ingested event is indistinguishable from a broker-ingested one in the chain.
Minimum role: service-class, or the scoped portal credential (portal:audit:write, #1441 — the portal’s session security events arrive citizen-class; the exchange-audit sinks keep their service arm). Any other principal returns 403.
Callers post this best-effort / fire-and-forget — a canopy-security outage must never break the applicant flow, so the portal logs and drops failures rather than retrying them into the request path.
Request: AuditEventIngestRequest
{
"event_type": "applicant.session.minted",
"source_service": "canopy-portal",
"action": "created",
"resource_type": "applicant_session",
"resource_id": null,
"user_id": null,
"user_role": null,
"ip_address": null,
"household_id": null,
"metadata": { "flow_kind": "resume" },
"event_timestamp": null,
"programs": []
}
event_timestamp defaults to the ingest time and metadata to {} when omitted. household_id is parsed to a UUID for the typed audit_events.household_id column; a non-UUID drops to NULL (it never breaks the hash chain, which hashes content + timestamp).
programs (#1519) is the publisher’s program-scope assertion for the row’s audit_events.programs column: [] = asserted program-neutral (visible to every scoped worker view), ["snap", …] = the named storage slugs, omitted/null = no assertion — the store then derives from the event-type routing key or the curated neutral families, else stores NULL (invisible to scoped worker views; see the worker-portal audit model in authorization-inventory.adoc). An assertion containing an unknown slug is rejected as a whole — the row stores NULL, with no fall-through to derivation (a malformed assertion must not be partially honored or silently re-derived). Like household_id, the column is OUTSIDE the frozen v1 chain hash (dedup_key precedent — the 20261128000000 migration header records the posture).
The endpoint is mode-split (#1207, ADR-014 Amendment 7; dormant until the #1279 cutover flips CANOPY_SECURITY__CHAIN_V2_APPEND_ENABLED):
-
v1 (flag off, today’s production): Response (202): accepted and synchronously chained (no body). 403: caller is not service-class.
-
chain-v2 (flag on):
202is REDEFINED as accepted + durably staged — the row is idempotently staged (chain_append_staging) and the per-shard drainer chains it asynchronously. The body carries the server-minted receipt{"event_id": "<uuid>"}— the event’s permanent replay identity. A client retry WITHOUT its own idempotency key mints a NEW event (intentional: replay identity belongs to the server-minted envelope id, not the request body). 422:metadatacarries an integer outside the RFC 8785-safe range (±(2^53 − 1)), or — since #1205 MR-2 — the raw body fails the JSON number fence (#1285, plan D14: a number token whose decimal value differs from its f64 round-trip, the collision class hash recomputation cannot see) — the client-traceable I-JSON classes; any other build failure is a redacted 500. 503 (fixed detailaudit staging at capacity): the staging admission cap is reached — retry later.
The auth.token_exchange stream (#1424, OIDC A1)
The RFC 8693 exchange audit stream (ADR-023 Decision 6; purposes frozen by
ADR-043 A1) rides this endpoint with three contract additions, all scoped to
event_type = "auth.token_exchange" and inert for every other type:
-
Frozen purpose vocabulary (422).
metadata.purposemust deserialize toworker_request | orchestrator_fanout(canopy_contracts_security::events::TokenExchangePurpose). Anything else — including the retired ADR-023citizen_upload/background_jobcodes (neither path exchanges, ADR-043 A1) — is refused with a 422 naming the frozen vocabulary. The MQ wildcard arm enforces the same gate (a violating delivery nacks to the DLQ). Both v1 and chain-v2 ingest arms gate BEFORE the mode split. -
Idempotency by exchange
jti(server-derived). canopy-security derivesdedup_key = "auth.token_exchange:" + metadata.jtiitself (event_parsing::derive_dedup_key— the emitter cannot forget or drift the key) and the v1 chain insert isON CONFLICT DO NOTHINGagainst the partial unique indexaudit_events_dedup_key_uq. A replayed grant — the fail-closed broker sink retrying a committed-but-unacknowledged POST — answers 202 and chains exactly one row. Denials carry nojti(no token was minted) and are deliberately non-idempotent. Thededup_keycolumn is deliberately OUTSIDE the frozen chain-hash input set (changingAuditChainInputswould break verification of every existing row); the jti’s tamper evidence rides the hashedmetadata. The uniqueness horizon is the live table — the archive mover does not carry the column, which is weeks of horizon against a sink retry window of seconds. -
chain-v2 degradation (tracked). The dormant staging arm dedups only on the server-minted
event_id; until #1498 lands (blocks the #1279 cutover) a sink retry under chain-v2 can double-stage — logged loudly at ingest, and benign in the double-audit direction only.
The typed metadata shape is
canopy_contracts_security::events::TokenExchangeAuditMetadata — identifiers
and enum codes only ({outcome, reason?, exchanger_client_id, audience,
granted_scope?, purpose, exp?, jti?, subject_jti?, correlation_id?}); the
emitting sink (canopy_auth::ChainAuditSink) sets the structured row fields
to action = granted|denied, resource_type = auth, resource_id = the
target audience, user_id = the subject (the broker’s (unvalidated)
sentinel for pre-validation denials, recorded honestly).
Breach Alerts
GET /v1/security/alerts
List breach detection alerts.
Query parameters: limit, offset, status
Response (200): Array of BreachAlert objects.
GET /v1/security/alerts/{id}
Get a single alert.
Response (200): BreachAlert object.
Response (404): Alert not found.
PATCH /v1/security/alerts/{id}
Update alert status (acknowledge, investigate, resolve).
Request: UpdateAlertStatus
{
"status": "investigating",
"resolved_by": "auditor@example.gov"
}
status is required; resolved_by is optional.
Response (200): updated BreachAlert object.
Response (404): Alert not found.
Integrity and Summary
GET /v1/security/summary
Get aggregate audit statistics (per-service action counts) over a trailing time window.
Query parameters: days (optional) — trailing window over created_at; defaults to 30, clamped to a 366-day cap. The window is mandatory server-side (#1229): there is no unbounded full-table aggregate path, and the query rides the idx_audit_events_created_at_id range with a statement timeout so an abandoned client cannot leave the aggregate running on the audit-ingest DB.
Response (200): Array of AuditSummaryRow objects (source_service, action, count) for events in the window.
audit_events rows only — a window wider than the operator-set archive
age threshold undercounts by design (archived rows are excluded).
Chain Verification (/v1/security/chain/*)
The unified chain-verification namespace (#1205, ADR-014 Amendment 9; plan
chain-v2 verifiers D8). ONE namespace
replaces the historical scatter, pre-1.0 with zero compat:
GET /v1/security/verify-chain and POST /v1/security/fti/chain-verify were
deleted in #1205 MR-2 (the typed ChainVerificationResponse contract died
with them; every consumer — worker portal, CLI, test-lib, citation template —
migrated in the same MR), and GET /v1/security/fti/chain-status — the last
survivor of the old FTI scatter — is deleted in #1206 MR-3 (its
FtiChainVerification wire DTO and ChainStatusInterim.last_verification
evidence field die with it; the OpenAPI path count settled at 16 until the
#1208 archive-run poll endpoint made it 17). The FTI (Pub 1075)
chains of canopy-tanf and canopy-medicaid are served by the SAME four
endpoints below via family=fti&service=….
Auth (all four endpoints): the existing service-or-admin arm — a
service-class token passes; any other principal must carry admin (403
otherwise). Job polling is additionally requester-scoped (below).
Dormancy (#1279): the background verifiers run only when
CANOPY_SECURITY__CHAIN_V2_VERIFY_ENABLED=true (default false, dormant
until the #1279 cutover provisions the LOGIN carriers + verify-pool URLs
the flag — Configuration Reference).
Each family is dormant or live independently: the audit family follows
the flag + its verify-pool URL, and each FTI family (#1206 MR-3) follows the
flag + its own CHAIN_VERIFY_TANF_DATABASE_URL / …MEDICAID… — one
program DB outage degrades that family only. While a family is dormant:
GET …/chain/status reports unknown with reason verifier_disabled → 503
(the #1245 fail-closed posture, preserved by status code), and
POST …/chain/verify + GET …/chain/attest return 503
verifier_unavailable (no job is ever queued for an unconfigured target).
One exception outranks dormancy on the fti arm: a latched legacy v1
breach row forces state: "breached" with reason legacy_breach_latched
(X8 — the #1245 "a breach is never silently swallowed" posture, Pub 1075 §9
reportable; retires with the fti_chain_verifications table drop at #1279).
Wire DTOs: crates/canopy-contracts-security/src/chain.rs — every closed
vocabulary is a real enum with its wire strings test-pinned. CLI parity
(ADR-007): canopy security chain-status / chain-verify / chain-attest.
GET /v1/security/chain/status
C6 verification status for one family target (ADR-014 Amendment 8; the derivation precedence is plan D6).
Query parameters:
| Parameter | Type | Description |
|---|---|---|
|
enum |
|
|
enum |
FTI service ( |
Response (200 or 503): ChainStatusResponse — the SAME typed body on
both codes. HTTP mapping: healthy/verifying → 200;
unknown/stale/error/breached → 503, so a consumer reading only the
status code fails closed while a typed consumer still gets the full picture.
The body carries state (the six-state machine), reasons[] (every firing
derivation input — the closed StatusReason vocabulary), epoch, per-shard
coverage (shards[]: tail verified-through / head / lag / stamps + scrub
cursor / target / cycle stamps), backlog (audit family only —
staged / parked / inbox-parked / DLQ depth; null for fti, typed
applicability), trusted_manifest (the verifier-checked anchor: id / seq /
age), and — on breached — the oldest unresolved incident_id plus its
typed breached_position.
POST /v1/security/chain/verify
Enqueue a durable manual verification job — never a synchronous walk (the #1245 OOM class stays dead). Jobs are token-claimed, target-scoped, and crash-safe: the work definition (instance / epoch / per-shard targets) is captured once at first claim, a reclaim resumes the SAME vector, and coverage is all-or-nothing. ONE active job per target (family, or family + service); manual runs never feed the status machine in either direction.
Request: ChainVerifyRequest
{"family": "audit", "loop": "family-full"}
loop ∈ {tail, scrub, family-full}, default family-full (tail + scrub
census + manifest). service is required exactly for family=fti.
incident_id (optional) marks a revalidation job for the
incident-resolution runbook
(Security Operations): it bypasses the family
halt gate and must run the incident’s DETECTED loop (or family-full).
Response (202): ChainVerifyJobAccepted — {job_id, poll_url}.
Response (409): verification_in_progress — an active job already
exists for the target; the body carries its job_id (idempotent — the
caller learns the in-flight id, no duplicate work).
Response (503): verifier_unavailable — queue at
CHAIN_JOB_MAX_QUEUED, verifier disabled, or family unconfigured.
GET /v1/security/chain/verify-jobs/{id}
Poll one verify job. Requester-scoped: service callers see only their own jobs; admin sees all; an unknown or foreign id returns 404 (existence is not disclosed across requesters).
Response (200): ChainVerifyJobStatus — state ∈
{queued, running, done, error}, requested_loop, attempts (reclaims
increment), the finalizing run summary once done, and error_code ∈
{coverage_incomplete, verifier_error, integrity_rejected, crashed} once
error.
GET /v1/security/chain/attest
Per-event verification attestation — the citation-PDF input ("Cite for hearing" refuses to issue without it).
Query parameters: event_id (uuid, required), family, service (same
rules as status).
Response (200): ChainAttestation. attested: true iff the position is
found (view-mediated, indexed, across archive ∪ live), the row’s
(instance, epoch) match the ACTIVE topology, seq sits within BOTH the
shard’s tail verified_through AND the trusted (verifier-checked) manifest
tip, and the family state is attestable (healthy/verifying). Otherwise
attested: false with reason ∈ {unknown_event, newer_than_checkpoint,
beyond_trusted_manifest, state_not_attestable, verifier_unavailable,
foreign_topology}.
Request-error matrix
The closed request-error vocabulary (plan D8 — each row is a named test; this table is the doc source of truth):
| Condition | Status | error code |
|---|---|---|
|
400 |
|
|
400 |
|
|
400 |
|
|
400 |
|
|
400 |
|
|
400 |
|
|
404 |
|
active job exists for the target (verify) |
409 |
|
queue at |
503 |
|
Archive Management
The async, durable archival protocol (#1208, plan
audit-archive-async) — the chain-verify-jobs
pattern applied to archival. It replaced the #1245 interim 503 (the unbounded
one-transaction mover was deleted for its OOM hazard, genesis-breaking boundary,
and ON CONFLICT DO NOTHING silent loss; this protocol is its structural fix:
per-chunk committed transactions, an inserted == deleted loss-proof assertion,
and a chain-head exclusion guard). A durable audit_archive_runs row is the unit
of work AND the accountability record; the always-spawned in-service runner
services it asynchronously. Operator procedures:
Security Operations › Archive Management.
POST /v1/security/archive
Enqueue a durable archive run — never executes inline. The run moves rows
whose received_at is older than the requested age threshold (cutoff computed
by the database clock) from audit_events to audit_events_archive in bounded,
per-chunk-committed transactions, always retaining the chain-head row.
Minimum role: admin only, USER-ONLY (#1427): service-class tokens are
always 403 (service_class_on_user_only), and under enforcement the
bearer must be an exchanged per-target token carrying admin. The run
row records requested_by = admin:{sub} via EffectiveUser (scheduled
runs record scheduler), so the requester must be a person.
Request: ArchiveRequest
{"archive_after_days": 2555}
archive_after_days is an age threshold, not a retention value — the
archive retains rows indefinitely, so archiving early shortens nothing
(retention policy — floors, legal hold, purge — is #1303). Sanity domain
1..=36500; outside it → 400.
Response (202): ArchiveRunAccepted — {run_id, poll_url}; the Location
header carries the poll URL.
Response (400): archive_after_days outside 1..=36500.
Response (403): caller lacks admin role (service tokens included).
Response (409): an archive run is already queued or running — ONE active
run total. The body carries that run’s ArchiveRunAccepted handle (no
Location header): the caller learns the in-flight id, no duplicate work.
Response (503): database pool exhausted — retry later.
Idempotency: the run row is durable, so a replay under the same
Idempotency-Key returns the stored handle (no transient-409 class exists).
Use a fresh key per continuation run — e.g. when re-POSTing after a
more: true outcome to drain a backlog.
GET /v1/security/archive-runs/{id}
Poll one archive run. Minimum role: service-class token or admin.
Response (200): ArchiveRunStatus — the durable run row projected onto the
wire: run_id, state ∈ {queued, running, done, error}, requested_by
(admin:{sub} or scheduler), requested_at, the frozen config snapshot
(archive_after_days, chunk_size, max_chunks_per_pass), attempts
(lease-expiry reclaims increment; the ladder finalizes error/crashed with
progress intact), the per-chunk committed progress (chunks_committed,
rows_archived — durable and pollable mid-run and after a crash), more,
error_code/error_detail once error, and started_at/finished_at.
error_code is the closed set {duplicate_overlap, upgrade_state_unrepaired,
statement_timeout, db_error, crashed} — each maps to a runbook procedure
(Security Operations).
more semantics (set at done): the pass ended on a full chunk, so more
movable rows may remain (this coexists with the retained chain head — more
is about the chunk budget, not the head). more: true also pulls the
scheduler’s next due time forward to the catch-up cadence, so backlogs drain
without operator action.
Response (404): unknown archive run.
GET /v1/security/archive
One keyset page of archived audit events, newest first.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
|
integer |
Page size, domain |
|
date-time |
Keyset cursor half — pair with |
|
uuid |
The other keyset cursor half. |
Response (200): Array of AuditEvent objects, ORDER BY received_at DESC,
id DESC. Page 2 = pass the last row’s (received_at, id) as the cursor.
Response (400): limit out of domain, or a lone cursor half (validated before the query — negative paging can never reach PostgreSQL).
offset,
source_service, event_type, action, household_id) are removed
(#1208 decision 14/P1) — they were advertised but ignored, and filters over an
unboundedly-growing archive without per-filter indexes recreate the O(n) trap.
Windowed historical needs ride the FOIA export union
(GET /v1/export/audit-events).
Export
GET /v1/export/audit-events
User-only (admin or quality_control) bulk export of the audit log (#1427: service bearers 403; enforced routes require an exchanged per-target token).
Query parameters:
| Parameter | Type | Description |
|---|---|---|
|
date-time |
Inclusive start of the export window (RFC 3339 / ISO 8601). Defaults to 24 hours before |
|
date-time |
Exclusive end of the export window. Defaults to "now" if absent. |
|
string |
Output format: |
|
integer |
Row cap, default 10 000, hard cap 50 000. |
|
string[] (repeated) |
Same program-scope visibility predicate as |
Response (200): Audit events within the requested window (JSON array of AuditEvent, or CSV per format). The export reads archive ∪ live (#1208): a UNION ALL over the twin tables with explicit columns, ordered event_timestamp ASC, id ASC — the window spans the archive seam without truncation.
Response (400): Invalid time window or limit.
Response (403): Not a user-only-admissible bearer — service-class token, or the caller lacks the admin/quality_control role.
Error Codes
| Code | Meaning |
|---|---|
400 |
Invalid time window or limit ( |
401 |
Missing or invalid JWT |
403 |
Requires admin role (or a service-class token where the endpoint takes the service-or-admin arm; |
404 |
Event or alert not found; unknown/foreign verify-job id ( |
409 |
|
503 |
|
Background Processing
canopy-security runs these background tasks:
-
Event subscriber — wildcard RabbitMQ consumer that persists every event to
audit_eventswith SHA-256 hash chain linking -
Breach detection — periodic scan (60-second cycle) checking for privilege escalation patterns, abnormal access volumes, and FTI access anomalies. Generates
breach_alertrecords when thresholds are exceeded. -
chain-v2 verifiers (#1205 audit; #1206 MR-3 the FTI families — all dormant until #1279; each runs only when
CANOPY_SECURITY__CHAIN_V2_VERIFY_ENABLED=trueAND its family’s verify-pool URL is set) — one task per configured family (audit, fti/canopy-tanf, fti/canopy-medicaid), each ordered by its FAMILY lease: halt gate → manual verify jobs first → manifest check + structural census → per-shard tail + scrub loops under a global pass budget. Verify pools parse at boot and connect lazily; one program DB outage degrades that family only. Integrity findings latch incidents (the family halts until the incident-resolution runbook clears them); coverage feedsGET /v1/security/chain/status. Design: plan chain-v2 verifiers D3–D6. -
audit-archive runner (#1208) — always spawned (a dormant scheduler still services manual admin runs, else the 202 handle would lie), delayed first tick, non-gating
audit-archivecheck in/readyz. Per tick: claim a queued (or expired-lease)audit_archive_runsrow → preflights (upgrade-state + first-chunk duplicate overlap, each a typed refusal on the run row) → the chunk loop (≤archive_max_chunks_per_passatomic moves, each followed by a token-fenced progress heartbeat) → finalize. The scheduler arm is dormant unlessCANOPY_SECURITY__ARCHIVE_SCHEDULER_ENABLED=true: when enabled, a transactional due-state row (audit_archive_schedule, Skip semantics — a week of downtime is ONE claim, no burst catch-up) enqueuesschedulerruns at thearchive_interval_secscadence, and amore: trueoutcome pulls the next due time forward toarchive_catchup_interval_secsso backlogs drain boundedly.GET /v1/security/eventsandGET /v1/security/summaryare designated hot-only: rows older than the operator threshold live in the archive surfaces (by-id, export union, fact-history, archive GET).