Interfaces  ·  EventLake design (Hearth)

Volume 08 — Interfaces & Extensibility

REST & gRPC · Query Surface · Plugins · GenAI · Protocols · Integration Map
Generated 2026-10-27 · clean-room · permissive-only · /Users/jatinnagpal/Documents/GitHub/siem

Volume 08 — Interfaces & Extensibility

Every door in and out of the platform: the REST management plane, its machine-first gRPC twin, the analyst SQL surface, the sandboxed plugin path, the GenAI/agentic seams and MCP, and the governed strategy by which protocols and OEM products become supported at all.

0. Scope

This volume owns the boundary layer — everything an outside caller, an agent host, a partner engineer or another product touches, plus the governance that decides what may become a door:

This volume does NOT own:

Not owned here Lives in
The charter, the four-plane doctrine, the AGI doctrine's invariants and autonomy ladder, the roadmap, the strategic review 00-charter-and-strategy.md
The event model and canonical schema, the lake, ingestion semantics, the decoders' internals, vector recall, report composition 01-substrate-plane.md
Detection semantics and coverage — including llmsec/aispm/agentsec, the arms that watch the AI this volume connects 02-analytics-plane.md
The conductor's guardrail chain itself, SOAR playbooks, Firewall, Validation, CTEM, EASM, cases and hardening 03-execution-plane.md
Identity, MFA and the F5 RADIUS bridge, Messages, EventLake Authenticator, zero-trust doctrine, the directory/policy plane, crypto-agility 04-identity-and-policy-plane.md
Deployment, sizing, the ITOps/NMS collectors, resilience drills, the operator runbooks these surfaces are served from 05-infrastructure-and-operations.md
Compliance regimes, AI governance conformity, the trust center's policy, lawful tiers, the threat model, supply-chain verification 06-governance-and-trust.md
Licensing and metering policy, service tiers, the customer console, hosting cost, company formation, GTM 07-commercial-and-gtm.md

Five boundaries worth stating twice:

  1. Interfaces are not a fifth plane. They are the skin over the four (substrate · analytics · execution · infrastructure). Nothing here decides what the platform does; every door merely exposes a capability another volume owns, under one shared authorization path.
  2. There is no second, weaker door. REST, gRPC, MCP, SQL and federated search all resolve the same credential to the same Principal, force the same tenant, run the same clearance masking, and write the same access audit. A surface that needed its own security model would be a defect, not a feature.
  3. No interface actuates. Every world-changing verb — REST POST /v1/actions, gRPC Command/ProposeAction, an MCP platform_propose_action, an agent's action tool — terminates at the conductor gate. The gate is Volume 03's; the refusal to bypass it is this volume's invariant.
  4. The strategy half governs the code half. §2.6–§2.7 decide whether a protocol may be decoded natively, as a sandboxed plugin, or not at all. That decision is logged before the decoder exists; it is not reconstructed afterwards from what someone happened to build.
  5. The clean-room and permissive-licence constraints bind the core, not the platform. That single sentence is why a GPL or proprietary decoder can run as a sandboxed plugin where it could never be a built-in — and it is the whole reason the extensibility story exists.

Exempt living documents this volume defers to (never restated here, always cited):


1. High-level design

1.1 Context — position in the four-plane model

The four planes are internal. A customer, an auditor, a partner's agent platform and a migrating SOC analyst all meet the platform at its edge, and that edge is deliberately narrow:

              ┌──────────────────────── the doors (this volume) ─────────────────────────┐
  operators → │  REST /v1/*   ·  gRPC eventlake.v1.{Query,Command}  ·  POST /v1/query/sql    │
  machines  → │  POST /v1/mcp (MCP server)  ·  /scim/v2/*  ·  /.well-known/security.txt   │
  agents    → │  GET /v1/agents · POST /v1/agents/{id}/run                                │
              └───────────────────────────────┬──────────────────────────────────────────┘
                                              │  ONE auth path → Principal{tenant, role, Platform}
                                              │  ONE RBAC verb check (deny by default)
                                              │  tenant FORCED · clearance MASKED · access AUDITED
                                              ▼
                                        assembly.Spine  (Volume 01 assembles it)
                                              │
        ┌─────────────────────────────────────┼──────────────────────────────────────────┐
        ▼                                     ▼                                          ▼
   the lake (V01)                    the analytics arms (V02)              the conductor gate (V03)
        ▲                                                                              │
        │                                                                    Actuator seam → the edge
        │
   ┌────┴──────── the extension paths (this volume) ─────────────────────────────────────┐
   │ built-in decoder (clean-room, open spec)  ·  signed WASM plugin (wazero sandbox)     │
   │ MCP client → external server  ·  connector manifest  ·  never-drop passthrough       │
   └─────────────────────────────────────────────────────────────────────────────────────┘

Two asymmetries are load-bearing. Reads fan out; writes funnel in. Any surface may read (subject to the same gates), but every write that changes the world converges on one conductor chain. And extension is inward-only: a plugin, a connector, an MCP tool or a decoder adds events to the one lake and proposals to the one gate — never a new actuation path, never a private store.

1.2 Responsibilities & boundaries

Responsibility Owned here Boundary
Authenticate a caller to a Principal ✅ The credential's lifecycle (enrolment authority, tenant provisioning registry) is spine state; this volume owns the doors onto it
Deny-by-default RBAC, tenant forcing, clearance masking, access audit ✅ The clearance ladder and the compartment/need-to-know semantics are the identity/policy plane's (V04); this volume enforces them at the edge
The endpoint/RPC/tool catalogue and its wire shapes ✅ What each endpoint computes belongs to the owning volume
Transport implementation (HTTP/1.1+h2, gRPC framing, JSON-RPC, SCIM, SigV4) ✅ All clean-room over public specs — no transport pulls a dependency into the core
The SQL grammar, its compiler and its bounds ✅ The lake scan it compiles onto is V01's
The plugin ABI, signing, sandbox policy, conformance battery ✅ The wazero engine lives in the perf tier (conduit-perf/wasmrt) per the D5 two-tier doctrine
The LLM/embedder/agent/MCP seams and their governance ✅ The arms that watch the AI (llmsec/aispm/agentsec) are V02's; the invariants and autonomy ladder are V00's
Which protocols get supported and how (category, path, revisit trigger) ✅ The decoders themselves are V01's decode/* packages
Deciding whether an action executes ❌ The conductor (V03). This volume only refuses to bypass it

1.3 End-to-end data / control flow

(a) A read — the same path on every surface. GET /v1/events?category=alert&entity=host:db-1:

  1. Authenticate. Authorization: Bearer <key> (or X-API-Key, or the D2 cookie session, or gRPC's authorization metadata header, or a SCIM bearer token on /scim/v2). Keys are stored only as SHA-256 hashes and matched with subtle.ConstantTimeCompare — the raw secret is never held and the lookup is constant-time. Unknown ⇒ 401.
  2. Authorize. The verb check (canRead here) is deny-by-default. An authenticated principal lacking the verb ⇒ 403.
  3. Force the tenant. The query's tenant is overwritten from the principal — never read from the request. The only deliberate exception is the additive Platform flag (§2.1.2).
  4. Read through assembly.View/ViewPage. Rows are masked by the caller's clearance, withheld by the need-to-know compartment gate, and pseudonymized under an Art. 88 monitoring profile where declared.
  5. Audit. One it/audit/access event is filed under the tenant whose data was read, carrying actor/role/records and — when the query was predicated — a selector. Its absence is the signal: an unbounded read records no selector at all, which is how a full-estate sweep is told from a targeted lookup of the same size. When server-side withholding hid matching rows, a candidates count records the pre-withholding reach, and its absence means nothing was withheld.
  6. Serialize. Canonical envelope by default; ?format=ocsf serializes the same already-masked rows through ocsf.FromEvent, so interchange is pure serialization and never a second, weaker read.
  7. Page. limit is clamped to 1000 server-side and an opaque next_cursor anchors on the last row's stable sort position, so rows appended mid-walk neither duplicate nor skip.

(b) A write — the funnel. POST /v1/actions {"kind":"isolate-host","target":"host:crown-1",…}:

  1. Auth + canAct, tenant forced, and the risk attributes re-derived server-side — crown_jewel from CMDB criticality, reversible from the action kind, confidence clamped to [0,1]. A proposer cannot under-report risk to skip a hold.
  2. actions.Execute runs the guardrail chain → executed / awaiting-approval / denied.
  3. A hold is released by an admin at POST /v1/actions/{id}/approve (same-tenant only; another tenant's pending action is invisible, a 404).
  4. The undo is also a proposal: compensates:<request id> files the same kind+target back through the same chain, routing actuation to the actuator's Rollback. Inherently-irreversible kinds refuse.
  5. Machine surfaces are stricter, never looser: actions.Propose (the MCP/agent door) downgrades even a chain-allow to a hold, so an AI-originated action's outcome is only ever awaiting-approval or denied.

(c) An extension — from a blind frame to a governed decoder. This is the loop that makes coverage scale past what EventLake can staff:

  1. A frame arrives from a source with no decoder. assembly.IngestRaw falls through to the passthrough instead of dropping — the raw bytes are kept (event.Original), and a visible un-parsed event carrying size, timing and the tap's peer endpoints lands on the lake. Volume, cadence, peer-set and silence are alertable with zero field-level understanding.
  2. The first frame from a new source announces a one-time it/platform/unknown-source discovery event (the new-talker signal), and schemaread.Store keeps per-(source, format) counters that survive the raw RAM quota's eviction — so the demand history outlives the bytes.
  3. GET /v1/sources/undecoded serves the backlog ranked by real unparsed volume. This is the decoder priority list, measured rather than guessed; a platform without a never-drop substrate cannot run the loop at all.
  4. The backlog is answered one of four ways: a native clean-room decoder (open spec — Category 1/2), a signed WASM plugin (closed/proprietary/customer-private — the licence never touches the core), an A7 connector-onboarding proposal (the agent infers a parse rule from retained samples and files it at the gate), or a logged decision record that the protocol stays passthrough-only for now.
  5. Whichever lands, assembly.ReparseRaw() runs the retro-reparse: the retained history becomes structured retroactively. A customer deploying into an estate full of undecoded protocols gets value on day one and retroactive depth the day the decoder arrives.

1.4 Invariants that must hold under any change

  1. One authorization path for every transport. A new surface reuses lookupKey → Principal → the verb check → tenant forcing → View/ViewPage → the access audit. If a surface cannot reuse them, the surface is wrong.
  2. The tenant is forced from the credential, never taken from the wire. The SQL grammar makes this structural: tenant is not expressible as a predicate, projection, grouping or order key, because a grammar must not offer a knob the server would ignore.
  3. Propose, never execute. No adapter, agent, MCP tool or plugin ever calls an actuator directly. The only world-changing verb an AI surface has is an actions.ActionRequest.
  4. The client is never the security boundary. An MCP host, a copilot UI, a mobile app or a copied binary is powerless: entitlement, RBAC, tenancy and the gate are all server-side.
  5. No silent caps. Every truncation is declared on the wire — scan_truncated/truncated on SQL, partial + per-source rows on federated search, dropped on geo tracks, dense_skips on backfill, spool_legs refused whole rather than half-applied. A bounded answer that reads unbounded is a lie.
  6. Third-party code runs sandboxed; it never links. A decoder plugin gets bytes-in/events-out and not network, filesystem or clock. CapDecode only — the engine refuses a descriptor requesting more, fail-closed, today and until a capability-gated host surface is deliberately designed.
  7. An undecodable source is visible, never blind. Passthrough is the default posture for unknown sources, not a fallback — and EventLake never pretends to decode ciphertext.
  8. Every AI call is telemetry on our own lake. The copilot, the agent runtime and the MCP server all emit it/ai/* events, so the platform that sells AI-security detection never runs its own AI unwatched.

2. Low-level design

2.1 The REST management plane

conduit/api is the single typed surface over the assembled platform (assembly.Spine) — RFP SIEM #1, one management plane. The portal (ui_kits/portal) and every integration talk to this, never into packages. It is stdlib-only (net/http, Go 1.22+ pattern routing); the gRPC surface (§2.2) is a drop-in over the same spine reusing the same auth/tenant/RBAC/masking path, and the web UI is a client of these endpoints. The full catalogue is §4.1.

2.1.1 The two rules enforced at the edge

Deny-by-default RBAC (RFP #5/#14) and one action gate. Both are described operationally in §1.3; what matters at the design level is where they sit: inside the process, never in a proxy. A bypassed reverse proxy exposes cleartext, never an unauthenticated surface.

The access-audit record deserves its own note, because it is the one place where an absence carries meaning:

Field Meaning Why the absence matters
actor / role who read, at what clearance —
records the delivered count — rows the reader actually left with —
selector the non-empty predicates of the query (entity/event_id/category/type/domain/activity/technique/text/labels/since/until, plus tip/case/compartment/schedule for the forensics/report doors) An unbounded read records no selector at all — so its absence is the full-estate-sweep signal. It describes the QUERY, not the results, and is not masked
candidates the pre-withholding reach, recorded only when it exceeds records A broad query most of whose matches were hidden below the reader's clearance must not read back as a narrow read. A read that withheld nothing carries no candidates at all

candidates is recorded on view/export/view-page/fedsearch only — the forensics views deliver a projected/bundle count in a different unit, and conflating the two would misreport both.

2.1.2 Roles, and the additive Platform flag

Role read ingest propose action / analyze approve held action read clearance
admin ✓ ✓ ✓ ✓ Restricted
analyst ✓ ✓ ✓ Confidential
auditor ✓ Internal
ingestor ✓ —
subject —
sensor ✓ (own telemetry only) —

Two of these are NOT interchangeable with the others, and both exist because a role's NAME is not its reach. subject (2026-08-20h) is a person who is only an app user: canReadSelf and no operator predicate at all — before it existed the narrowest choice was auditor, which reads the tenant's whole SOC estate plus the platform-wide intel set. sensor (2026-08-21i) is a MACHINE its owner enrolled: the two ingest doors plus the Sensor agent transport, and nothing else. It is not ingestor because ingestor — whose own doc line reads "ingest only" — measurably reaches 15 canIngest routes (including the federated-learning participant plane, POST /v1/capture, the ITSM callback and the evidence-artifact door), the 17 canAgent routes, and canVerifyMFA + canBeginAuth, i.e. a TOTP verification oracle. The ingest doors are therefore gated by canShipTelemetry (canIngest OR sensor) rather than by widening canIngest itself: widening the DOOR keeps the blast radius equal to the capability, widening the ROLE would have bought a consumer's laptop four unrelated planes. ⛔ sensor is absent from provisionableRoles, so no credential-issuing door can mint it into a bearer key — the only principal that carries it is an agent-kind device SVID, re-derived by the auth PEP on every request.

A separate, additive Platform flag (orthogonal to Role; false for every per-tenant key) marks an OEM super-admin authorized for cross-tenant control-plane reads — the only principal allowed the fleet-gated metering routes (canReadFleet / canManagePlans = admin and Platform). It is the one deliberate break of otherwise-universal tenant forcing; a normal customer admin can never reach another tenant's data.

The Platform flag is also the WRITE gate for every registry that has no tenant dimension (decision 2026-07-22). Most platform state is keyed by tenant, so a tenant admin writing to it governs only its own estate. Five registries are not: the single-event Sigma detector, the capture-plane YARA scanner, the intel indicator store, the deployment-wide DLP dataset index, and installed Rules (formerly Recipes) packs. A row there is matched against every tenant's telemetry, shares ONE id-space, and is retractable by whoever holds the id — so writing it is an OEM act (canManagePlatformDetections · canManageIntel · canManageDLP · canManagePacks, each admin and Platform), while reading stays canRead: a tenant that cannot curate estate content still needs to see what protects it.

The refinement that makes this liveable: where one door accepts BOTH kinds — POST /v1/sigma/rules, the SPL/KQL/ES-DSL import doors, POST /v1/adf — the check reads the submitted document, not the route. A correlation or search payload is still any admin's; a single-event Sigma or YARA payload takes the operator. Install and uninstall move together for the same reason: content a tenant admin may not retract is content they must not be able to load.

Recorded refinement (not built): per-tenant Sigma, intel and DLP registries would give the estate-wide half back to tenant admins safely. Until then the platform gate is the honest answer.

2.1.3 The session plane and inbound SSO

One session per app (standing decision 2026-06-29; screens code-wired 2026-07-02). A client authenticates ONCE per app at the front door (POST /v1/session) — web gets an HttpOnly · Secure · SameSite=Strict cookie plus a CSRF token, native gets a keystore/StrongBox-bound session; 30-minute sliding idle, 12-hour absolute lifetime. Every surface or tab of that app rides the SAME session, with per-surface access resolved server-side (entitlement + RBAC), transparently and never via a second login. GET /v1/session re-bootstraps a reload; DELETE /v1/session ends that one session and every tab riding it. A person's OTHER logins are a separate plane — GET /v1/me/sessions + DELETE /v1/me/sessions{,/{ref}}.

Step-up is not login. The few sensitive actions — release a held conductor action, present a wallet credential, approve a federated sign-in, EventLake Command approvals — re-prove the user via POST /v1/session/stepup under the 5-minute authStepUp freshness gate, framed "approve this action / confirm it's you", never "log in again". A stolen live session still cannot approve containment.

Both doors are PASSKEY-capable (owner decision 2026-07-30, api/passkeylogin.go). An operator may sign in and step up with the MFA number-match ceremony instead of a pasted key: POST /v1/session/passkey/begin (open — it IS the login) or POST /v1/session/stepup/begin raises the challenge, the enrolled device signs the nonce and the decision, and the approved challenge is traded on POST /v1/session / POST /v1/session/stepup. This closes a real hole: step-up used to re-present the same credential, so a stolen key satisfied both factors and "a stolen live session cannot approve containment" held only against session theft. Four properties the ceremony alone does not give are enforced here — an approval is single-use, purpose-bound (a sign-in approval cannot be spent as a step-up, which is the phishing path), identity-bound on step-up (only the session's own principal), and carries no new authority (the principal comes from the credential the binding points at, re-read at mint time, so one revocation closes both doors). Once a passkey is bound, that operator's key-only step-up is refused (403 step_up_requires_passkey) — an upgrade, not an addition, decided per-principal so a rollout locks nobody out mid-flight. Login itself is not yet passkey-mandatory: there is no per-tenant "require passkey" policy, and refusing keys fleet-wide would lock out every unenrolled operator.

Cross-app (EventLake Authenticator ↔ EventLake Command) there is no shared cookie: one identity federates via /v1/oidc/* (EventLake-as-IdP) and the operator entitlement layers on server-side.

Inbound enterprise SSO (W41) rides the same plane. An IdP registered with enforce_sso makes the tenant's console door its IdP: POST /v1/session REFUSES a local key's browser-session mint (403 sso_required, denial-audited) — except for an explicitly provisioned break-glass credential (Principal.BreakGlass, deny-by-default, at most a couple per tenant), whose every use is loudly audited (it/authentication · sso · break-glass, High) so the IdP-outage escape hatch is itself a monitored event. Machine bearer-key API calls are untouched — enforcement targets the human login, never automation. Every SSO success and refusal is an it/authentication event: the console's own front door feeds the detection substrate.

2.1.4 The enrollment bootstrap plane

POST /v1/enroll, GET /v1/enroll/challenge and POST /v1/enroll/renew are deliberately not API-key authenticated (zero-trust §8-C): a joining source has no standing platform identity yet, so its single-use join token (to enroll) or its authority-issued SVID + a fresh proof-of-possession (to renew) IS the credential, verified inside the enrollment authority. Issuing the token, by contrast, is an admin operator action (POST /v1/enroll/tokens, tenant forced) — and that mint says what kind of thing is joining (agent | app, 2026-08-24f), because the resulting SVID's authority is derived server-side from that claim on every request. ⛔⛔ An enrolment of the wrong kind fails LATE and silently: the credential verifies, the tenant is right, and the host is then refused at every agent door — which is why the RBAC refusal now names a device credential's lack of authority instead of rendering role is not permitted this operation with a blank where the role belongs.

A bad, expired or replayed token — or a failed proof — returns a generic 403; the specific reason is recorded as it/security/access-denied denial telemetry rather than leaked to the caller, and a token replay is scored High → alert → case. The registry and revoke sit on the authenticated management plane (GET /v1/enroll/svids, DELETE /v1/enroll/svids/{id}), and a revocation is refused at the auth gate and barred from renewal thereafter, riding as an it/security/enrollment activity=revoked event so it survives a control-plane replay.

2.1.5 Two surfaces that split authorization from execution

Validation (formerly Crucible, §11b). Minting an engagement (POST /v1/validation/engagements, /v1/crucible until 2026-09-28) is the operator sign-off — admin-only, step-up-gated for browser sessions, tenant and recorded authorizer forced from the principal, and max_tier parsing only passive | safe-active | validate: destructive is not representable on the wire. Running a campaign under it is act, and the conductor still holds every above-passive probe, so releasing a validation probe is the same human act, on the same queue, as releasing a containment. RoE refusals are denial telemetry, never a silent skip. Posture is projected live when the request omits it (CMDB assets + it/vulnerability/* events over a 30-day default window), and GET /v1/validation/posture previews the projection before anything is authorized. Tenancy is deny-by-default in both directions.

The sandbox (POST /v1/detonate). The on-demand analyst verb: content_base64 or a url in, a verdict + ATT&CK-mapped behaviors + extracted IOCs out. Unlike the automatic triage path (which gates on detection signals before spending a detonation), an explicit human submission runs unconditionally — the submission is the triage decision — while the per-kind profile still selects the environment. The clean-room StaticDetonator backend ships today (analysis:"static", executed:false — it analyzes without running); a live multi-OS dynamic backend drops in behind the same Detonator seam without changing this surface.

2.2 The gRPC / protobuf surface

Package name — eventlake.v1, with aether.v1 served as an alias until an owner date (2026-10-12y, nomenclature §13 Kind C). Both services (eventlake.v1.Query, eventlake.v1.Command) and every rich-error detail message are declared in eventlake.v1. A stub generated before the rename calls /aether.v1.<Service>/<Method>, and that path keeps working unchanged: the server folds it onto the current name BEFORE dispatch, so both spellings reach the SAME twin through the SAME REST door (one authorization path). Two things follow the caller's spelling: a refusal's Grpc-Status-Details-Bin Any.type_url (type.googleapis.com/aether.v1.* for an old stub, which can only resolve its own descriptor) and the unknown-method sentence. Use of the old name is counted — grpc_retired_package_calls_total — so the alias's retirement is set on an owner date from a measurement (owner-open-items.md §6mo, ask 2). Regenerate stubs against eventlake.v1; nothing else about a call changes.

Status: complete across reads, tails, watches and writes. Pilot shipped 2026-06-27 (2 unary RPCs); the read surface widened 2026-06-30 to seven unary RPCs; server-streaming (TailEvents/TailAlerts/ TailCases) and bidirectional streaming (WatchEvents/WatchAlerts/WatchCases) both shipped 2026-06-30; the conductor-gated write surface eventlake.v1.Command/{ProposeAction,ListPending, ApproveAction} shipped 2026-07-01. Package: conduit/api (grpc.go = the eventlake.v1.Query read/stream surface; grpc_command.go = the eventlake.v1.Command codecs + ListPending; grpc_bridge.go = the gRPC→REST twin bridge every Command WRITE is dispatched through). No new dependency.

REST-mutation parity (2026-09-26yu) — measured, not prose. Six more writes landed — eventlake.v1.Command/{TransitionCase,AssignCase,CommentCase,AddCaseTask,CompleteCaseTask,RecordFeedback} — and the two original writes moved onto the bridge. The ledger is a guard (conduit/api/grpc_parity_internal_test.go, go/ast over the route table): 8 of 386 auth-gated REST mutations have a gRPC twin, 378 remain (the denominator is a FLOOR; go test -v -run TestTheGRPCMutationParityLedger ./api lists every one). The Command service's declared scope, the routesActions family, is held EXACTLY: 14 untwinned rows (kill switch · approver policy · compartments · support desk), each named in the guard with why it waits.

Governance twins (2026-09-28rc). Nine more writes are served through the same bridge: eventlake.v1.Command/{EngageKillSwitch,DisengageKillSwitch,SetApproverPolicy,SetApproverRoster,RegisterCompartment, GrantCompartment,RevokeCompartment,RequestCompartmentRead,ApproveCompartmentRead,ExecuteCompartmentRead} (conduit/api/grpc_bridge_governance.go). Parity is now 17 of 389, with 372 remaining, and the family gap is 5 (the support desk). Seven of the nine are step-up doors. No cookie crosses the bridge, so each one reaches authStepUp as a bearer call: the posture a REST key already has, never a second one. ⛔ The kill-switch twins require a non-empty scope on the wire. The REST door reads an absent scope as "*" (every tenant) for a platform principal, and proto3 cannot tell absent from empty, so an unset field would otherwise halt the fleet. This is a wire-shape rule. Who may engage which scope is still decided only by the REST handler. ⚠ An emergency stop is now reachable from a second transport, at the same gate. The 26yu note named that as an owner-visible change, and it is recorded here rather than silently.

PAM twins (2026-09-29ug). Ten more writes, the whole privileged-access family, through the same bridge: eventlake.v1.Command/{StoreSecret,CheckoutSecret,RequestElevation,ApproveElevation,RevokeElevation,OpenSession, OpenProxySession,ApproveSession,ApproveProxySession,TerminateSession} (conduit/api/grpc_bridge_pam.go). Parity is now 27 of 364, with 337 remaining (the denominator fell 389 → 364 when 2026-09-28mj removed the retired route aliases). The ledger gained two ratchets: a twinned-count floor (27) and grpcFullyTwinnedPrefixes, which holds /v1/pam/ exactly — a new PAM mutation fails the build until it has a twin. The family was chosen because it has a machine client: a pipeline that elevates just in time, checks out a credential and lets the lease expire is the canonical secrets-automation shape. ⛔ Two responses carry a credential, and they carry it exactly as REST does: CheckoutSecret returns the vaulted value (Secret.value, set by that twin only), and the two proxy opens return the one-time connect_token, read from the REST door's explicit assignment. ProxyTicket has no token field, so the proxyTokenDisclosure property holds on this transport too. Five of the ten are step-up doors, reached as bearer calls exactly as the governance twins are. ttl_seconds rides as int64, so the REST door's count check sees the number the client sent: an int32 decode would have read 2^32+60 as a one-minute grant.

Segmentation twins (2026-09-30kd). Five more writes, all of intent-based segmentation, through the same bridge: eventlake.v1.Command/{SetSegmentPolicy,SetSegmentEndpoints,DistributeSegmentPolicy,AttestSegment,SimulateSegmentFlow} (conduit/api/grpc_bridge_segment.go). Parity is now 32 of 364, with 332 remaining. The twinned floor is 32, and /v1/segment/ joins /v1/pam/ as a fully-twinned prefix. The family was chosen because segmentation policy is the canonical GitOps object: a pipeline renders the intent set from a repo, declares it, distributes it and attests the fabric, with no browser in the loop. ⛔ The compare-and-swap token rides exactly as REST carries it. if_match goes in, the new etag comes out, and the bridge never reads, defaults or refreshes it. So a blind gRPC re-declare hits the same lost-update refusal a REST one does (409 → FAILED_PRECONDITION). Ports, priorities and the simulated port ride as int64, so the REST door's own 0–65535 check sees the number that was sent. An int32 field would have delivered 2^32+22 as 22 (SSH), a port the client never named. Labels are proto3 maps. The conductor's Verdict and RadiusScope stay the numbers REST serializes. deployed=false with an outcome set means a wave that ran and rolled back, on this transport as on REST.

Lawful-intercept twins (2026-09-30gp). The seven warrant-lifecycle writes, through the same bridge: eventlake.v1.Command/{RequestInterceptWarrant,AuthorizeInterceptWarrant,DenyInterceptWarrant,RevokeInterceptWarrant, ActivateInterception,DeactivateInterception,RecordInterceptHandover} (conduit/api/grpc_bridge_lawintercept.go). Parity is now 39 of 365 (the denominator rose by one REST door since 30kd), the twinned floor is 39, and /v1/lawintercept/ joins /v1/pam/ and /v1/segment/ as a fully-twinned prefix; its five reads stay REST-only. This is the most sensitive family on the surface, which is why it adds no gate of its own: RBAC (canApprove on the four approve-class doors), the per-call service entitlement (lawful intercept is in no tier preset), step-up at the bearer posture, the admin-act record, the ledger's four-eyes rule and the conductor gate on arming are all the REST door's. ⛔ The twins are registered in EVERY build, like the twelve REST routes. In the default brain (tier not compiled, B9-D3) the REST door answers 404 not_built, so the twin answers NOT_FOUND in the same sentence, and only to a caller who has already passed RBAC. valid_seconds and the handover bytes ride as int64. Activate and deactivate answer eventlake.v1.Outcome, the conductor's outcome and not "done": a held disarm comes back awaiting-approval with its reason, as it does on REST.

Integration twins (2026-09-30cn). Every REST write under /v1/connectors, fifteen of them, through the same bridge: eventlake.v1.Command/{EnableConnector,DisableConnector,SetConnectorRouting,RegisterPostureSource, DeletePostureSource,PollPostureSource,PutConnectorCredential,DeleteConnectorCredential,InstallPlugin,UninstallPlugin,TrustPluginKey,RevokePluginKey,RebindPluginKey, TrustTenantPluginKey,RevokeTenantPluginKey,InstallTenantPlugin,UninstallTenantPlugin} (conduit/api/grpc_bridge_connectors.go). Parity is now 54 of 365, the twinned floor is 54, and /v1/connectors/ is the fourth fully-twinned prefix; its GET reads (the catalog, one manifest, the source status, the key lists) stay REST-only. The bridge adds no gate: the estate switch and the estate plugin doors are Platform-only (canManageEstateConnectors / canInstallPlugins), the routing mask, sources and tenant plugin doors are the tenant admin's (canManageConnectors), poll-now is canAct, and the three trust grants (estate key re-bind, tenant key trust, tenant install) are stepped up at the bearer posture. ⛔ The supply-chain doors carry the module as REST does and re-validate nothing: wasm and public_key are proto bytes, base64-encoded by the bridge into the REST body's string, and the REST door decodes, caps, recomputes the digest (a client-sent module_digest is overwritten) and verifies. The one asymmetry is the frame: a gRPC message is capped at 4 MiB against REST's 8 MiB body, so a module at the 4 MiB module ceiling fits REST and not a gRPC frame. ⛔ route is optional bool: REST reads it as a pointer so an absent value is connectors.route.value_missing and never "unroute", and the bridge forwards it only when it is on the wire. A Platform principal's target tenant rides in the body on the POST doors and as ?tenant= on the three DELETEs and poll-now, each where its REST door reads it. The budget's integers ride as uint64, so an overflow of REST's uint32 fields is REST's refusal, not a silent truncation. No answer carries a compare-and-swap token or a secret: a registered posture credential is never echoed.

Support-desk twins (2026-09-30b). The ticket's five writes, through the same bridge: eventlake.v1.Command/{OpenSupportTicket,CommentSupportTicket,TransitionSupportTicket,AttachSupportBundle, EscalateSupportTicket} (conduit/api/grpc_bridge_support.go). They were the last untwinned rows of routesActions, so that family's gap is now empty and held so; parity is 59 of 365, the twinned floor 59, and /v1/support/ joins the fully-twinned prefixes (its three reads stay REST-only). All five take canAct; the tenant and the actor are the caller's (no request field names either), another tenant's ticket is the same 404 as a missing one, escalation is the OEM desk's side only, and a Platform reader's access to a customer's ticket is filed in that customer's audit shard — all REST's, on both transports. ⛔ consent is a plain proto3 bool on purpose: its absent value is false, which is the REFUSING value (support.bundle.consent_required), so a client that forgets the field is refused, never silently consented. No file crosses this door; the bundle is composed server-side and the answer carries its digest and size, never the document.

Analysis twins (2026-10-01an). Every REST write under /v1/analyze, forty-seven of them, through the same bridge: eventlake.v1.Command/Analyze{Flows,Fraud,UEBA,ICS,DNS,WAF,CIEM,CloudSec,CSPM,KSPM,SaaSExfil,K8sThreat,FIM,ProcThreat, LinThreat,MacThreat,WinThreat,LLMSec,EmailSec,VisionSec,BiometricGovernance,BiometricFairness,VisionPrivacyPosture, PhysicalLogical,APM,APIAuthz,SupplyChain,DesignSec,IaCSec,BackupSec,DLPSec,DBSec,PACSSec,MobSec,ITDR,AISPM,AgentSec,Infra, CapacityForecast,Vuln,PKI,TLSSessions,SourceHealth,ThreatPaths,Legitimacy,SoD,Memory} (conduit/api/grpc_bridge_analyze.go). Parity is now 106 of 365, the twinned floor is 106, and /v1/analyze/ is the sixth fully-twinned prefix (the family has no reads). The bridge adds no gate: every door is canAct (admin or analyst) under queryLimited, so a held query slot is REST's 429 sentence as RESOURCE_EXHAUSTED, and an arm runs over the caller's own tenant only. AnalyzeRequest{ since, until } rides as REST's ?since=&until= query, never a body, and carries no tenant: these doors are deliberately not cross-tenant scopeable. Each arm has its own finding type, so findings ride as google.protobuf.Struct transcoded from the REST answer rather than as 45 hand-kept schemas. The one mapping choice is that an integer a double cannot hold exactly (beyond ±2^53) rides as its exact decimal string, as protobuf's own JSON mapping does for 64-bit integers. The REST list key (findings · violations · hypotheses · judgements) folds into AnalysisResult.items, and a 2xx answer missing it is INTERNAL, never an empty list. /v1/analyze/mythos keeps its path (a join key) but its RPC is AnalyzeThreatPaths. The memory image is proto bytes that the bridge only base64-frames into content_base64; the frame is 4 MiB against REST's 64 MiB, so large images use REST. Bridge-wide (same session): an answer over the 4 MiB frame, buffered or once transcoded, is now RESOURCE_EXHAUSTED with a sentence saying the call was served and asking the caller to narrow the request. It was a bare INTERNAL before; an analysis over a wide window made it reachable by an honest client.

MFA enrolment-gate and co-sign twins (2026-10-05p). eventlake.v1.Command/BeginMFADeviceEnrolment (POST /v1/mfa/devices/enrolment; BeginMFADeviceEnrolmentRequest{} → MFADeviceEnrolmentChallenge{ challenge_id=1, number=2 (int32), required_factors=3 (repeated) }) and eventlake.v1.Command/CosignMFAChallenge (POST /v1/mfa/challenges/{id}/cosign; CosignMFAChallengeRequest{ id=1, device_id=2, number=3 (optional int32), signature=4 (bytes) } → MFAChallengeCosign{ id=1, status=2, cosigned_by=3 }); RegisterMFADeviceRequest gains enrolment_challenge = 3. Twinned floor 267 → 269.

MFA twins (2026-10-01gx). Every REST write under /v1/mfa, eleven of them, through the same bridge: eventlake.v1.Command/{RegisterMFADevice,RevokeMFADevice,ResolveMFAChallenge,SelfTestMFA,AddMFAFactor,EvaluateMFAPIN, SetMFAPolicy,SetMFATenantPolicy,SetMFATenantAttestation,AddTOTPAccount,RemoveTOTPAccount,VerifyTOTP} (conduit/api/grpc_bridge_mfa.go). Parity is now 117 of 365, the twinned floor is 117, and /v1/mfa/ is the seventh fully-twinned prefix (its reads stay REST-only; the self-scoped /v1/me/ twins belong to that prefix). The bridge adds no gate: the nine subject doors are canReadSelf + the mfa@self entitlement, so an operator key with no self half gets REST's 403 sentence naming the tier; the tenant floor is canApprove; the verify oracle is canVerifyMFA. Since 2026-10-22cw (row 461a) a RADIUS bridge's offline accepts report through the twin Command/ReportTOTPOfflineAccepts (canVerifyMFA, the REST door POST /v1/mfa/totp/offline-accepts); the ladder read GET /v1/mfa/totp/ladder stays REST-only like the prefix's other reads. The subject and tenant are the principal's on every subject door, and no request message names either. The tapped number is optional int32, and its absence crosses as absence: zero is a real number-match value (00), and an absent number is what selects the legacy proof statement, so a plain proto3 int would turn every legacy approval into "tapped 00" and fail its signature. A wrong number is REST's one sentence (proof-of-possession failed, deliberately the same as a bad signature) and the challenge is denied, fail closed. Proto3 absence on the two floor doors is REST's {}: an empty factors clears the floor, as on REST. Secrets cross only where REST returns them once (a minted TOTP factor secret, a new account's otpauth:// URI). One advisory does not cross: the oracle's Retry-After header — the bridge carries status and body, not headers, so a gRPC caller sees RESOURCE_EXHAUSTED or valid=false without the seconds.

Federated-learning twins (2026-10-01sc). All twelve /v1/fedlearn/ writes, through the same bridge: eventlake.v1.Command/{DeclareFedModel,OpenFedRound,AdvanceFedRound,AggregateFedRound,SubmitFedUpdate,AdvertiseFedRound, ConfirmFedPartition,ReportFedEquivocation,RevealFedShares,AckFedArtifact,PublishFedModel,ApproveFedPublish} (conduit/api/grpc_bridge_fedlearn.go, which pins every field number). Parity is now 129 of 365, the twinned floor is 129, and /v1/fedlearn/ joins the fully-twinned prefixes; its reads (board, registry, round material, artifact pull) stay REST-only. RBAC is REST's: canManageDetections to declare, open, advance or aggregate; canIngest for the six participant legs; canAct to publish; canApprove plus step-up to release a held publish. The participant legs are authenticated by the spine against the roster's declared Ed25519 key (T1-64) — all six since 2026-10-01fa, when the artifact ack (sig = 7, over fedlearn.ArtifactAckTranscript, tag eventlake/fedlearn/artifact-ack/v1) became the last to require one. A signature covers a transcript computed from the decoded struct, never these bytes, so a region signs the same thing on either transport. A forged or stranger post is refused on the gRPC door only because it reaches the same spine method. Weight vectors and holdout rows are repeated double, emitted packed and accepted packed or unpacked (a zero coordinate keeps its position). A NaN or ±Inf cannot be spelled in JSON, so it is refused as a malformed request rather than coerced.

⭐ The first refusals that carry DATA. A failed aggregate answers REST 422 with the excluded participants, and a regressed publish answers 409 with both measurements. A sentence-only gRPC refusal would hand a machine client the verdict and drop its evidence. The bridge now has an opt-in grpcTwin.refusalDetail hook that attaches the standard rich-error detail: grpc-status-details-bin, a google.rpc.Status carrying one Any of type eventlake.v1.FedAggregateRefusal{ repeated FedPoison excluded = 1 } or eventlake.v1.FedPublishRefusal{ string code = 1; FedPublishEval eval = 2 }. grpc-status and grpc-message are unchanged (INVALID_ARGUMENT / FAILED_PRECONDITION, REST's sentence). Every other refusal shape, including RBAC, a malformed body and the release door's superseded 409, gets no detail. grpc-status-details-bin is now predeclared beside Grpc-Status, Grpc-Message. Tests: a 35-row denial matrix (anonymous, auditor, ingestor, analyst against every door they lack); a whole round driven over gRPC only (the blind re-declare 409, the stranger and forged updates, the poisoner's exclusion, four-eyes on the release, and a regressed publish whose detail carries the candidate's log loss bit-for-bit as REST reports it); and the artifact ack compared door-for-door rather than pinned, because whether an unsigned ack is accepted is the spine's call. 7/7 mutants killed.

Platform-operator twins (2026-10-01j). All twenty-nine auth-gated writes under /v1/platform/, through the same bridge (conduit/api/grpc_bridge_platform.go): the tenant lifecycle (CreateTenant, SetTenantStatus, Begin/CancelTenantOffboarding, PurgeTenant, SetTenantJurisdiction), credentials (IssueTenantCredential, RevokeTenantCredential, IssueOperatorCredential, CreatePlatformInvitation), the licence registry and its queue (AssignLicense, UnassignLicense, Create/Fulfil/WithdrawLicenseRequest), billing and the customer's verification credential (SetVerificationOverage, Set/RevokeVerifyCredential), regulatory (ResolveScreeningReview, Record/Countersign/RevokeLIEligibility), operator access (CreateAccessGrant, RevokeAccessGrant), egress (Set/ClearEgressPolicy), the rights desk (ActOnConsumerRight) and the exposure corpus (LoadExposureCorpus, RunExposureWatch). Parity is now 158 of 365, the twinned floor 158, and /v1/platform/ joins the fully-twinned prefixes (its reads stay REST-only). Every door is a Platform predicate, so a customer tenant's own full admin is refused on all 29 in REST's sentence; 17 are step-up doors, reached at the bearer posture; bootstrap confinement, the LI countersign's four-eyes rule, operator self-mint refusal and the P0-7 grant gate are REST's on both transports. ⛔ One-time secrets — the minted key on tenant create, credential issue and operator issue, and the invitation's link (its fragment is the token) — ride on the minting answer exactly as REST returns them and nowhere else; the inbound verification api_key is never echoed (the answer is the slot's fingerprint). ⛔ The purge certificate rides as JSON bytes (certificate_json), because its Ed25519 signature covers json.Marshal of the certificate — re-spelled field by field it would not verify. The exposure corpus rides as the client's JSON document likewise (capped by the 4 MiB frame against REST's 32 MiB body). ⛔ Absence, door by door: purge's absent confirm_tenant can never match the path tenant, and an absent status or reason is refused, so those are plain fields; licence features absent and empty are one thing (no narrowing), but licence lines is REQUIRED on REST since 2026-10-01v — an omitted key is refused and only an explicit [] winds the tenant down — so the twin carries the wind-down as its own bool wind_down = 4 and no lines element reaches REST as the refused omission; and SetEgressPolicyRequest.tenant is optional and required on the wire, because its empty value is the ESTATE-WIDE default — an absent tenant is refused, an explicit "" still reaches the estate default. The bridge now names a missing path segment (missing required field: tenant) instead of always saying id.

Self-scoped twins (2026-10-01w). All ten auth-gated writes under /v1/me/, through the same bridge (conduit/api/grpc_bridge_me.go): SetMyLocale, CreateMyDeviceEnrollToken, RemoveMyDevice, RevokeMySessions, RevokeMySession, SetMyConsent, FileMyRight, SetMyReportSubscription, ReportMyDevicePosture and VerifyMyTOTP — and, since 2026-10-04aa, DisputeMyAccessRecovery (the holder's "This wasn't me", §13 row 180 B). Parity is now 172 of 369, the twinned floor 172, and /v1/me/ joins the fully-twinned prefixes (its reads stay REST-only). RBAC is canReadSelf on every door (any authenticated principal is "me"), so the gate is s.auth's other legs plus each handler's self-scope, which is the family's whole security property: subject and tenant are read off the PRINCIPAL on both transports and no request message has a field that names either. The tests prove it with a second person in the same tenant AND the same subject name in another tenant — a colleague, or riya@globex, cannot remove riya@acme's device, end her login by its ref, or have their revoke-all touch hers, each refused in REST's one not-found sentence. ⛔ Sessions: the bridge forwards no cookie, so a twin caller is a bearer caller and revoke-all keeps nothing (kept_current false), exactly REST's answer to a bearer; include_current still rides as REST's query parameter, because the audit row records it. A ref authenticates nothing on either transport. ⛔ One-time secret: the device-enrol token rides on the minting answer only; CreateMyDeviceEnrollToken is a step-up door reached at the bearer posture, and a DEVICE credential may not enrol another (REST's refusal). ⛔ Absence, door by door: SetMyLocaleRequest.locale and SetMyReportSubscriptionRequest.subscribed are optional, because REST reads both as pointers — an explicit "" clears the locale while absence is refused, and an explicit subscribed=false unsubscribes while absence (re)subscribes; a plain proto3 bool would have SUBSCRIBED a person asking to stop. An absent consent granted withdraws, as REST's omission does (the privacy-protective side). The phone's posture report rides as JSON bytes (report_json), forwarded verbatim so the versioned document has one parser. VerifyMyTOTP's wrong code is REST's 200 valid:false, never a refusal, and no answer or refusal echoes the code; the Retry-After header does not cross (the bridge carries no headers — the 429's sentence and code do).

Validation twins (2026-10-02u). All five auth-gated writes under /v1/validation/, through the same bridge (conduit/api/grpc_bridge_validation.go): CreateValidationEngagement, RunValidationCampaign, RunValidationCampaignFromThreatPaths, ScoreValidationCampaignCoverage and RunSelfEngagement. Parity is now 177 of 369, the twinned floor 177, and /v1/validation/ joins the fully-twinned prefixes (its reads stay REST-only). The gates are REST's own: minting a Rules of Engagement is authStepUp(canAuthorizeValidation) (admin, reached at the bearer posture), the three campaign doors are canAct, and the self-engagement run is canApprove. ⛔ The conductor still holds every above-passive probe a gRPC run proposes; the holds land in the one /v1/actions/pending queue and are released through the existing approve twin, so the twin adds no way to fire a probe. The engagement's tenant is forced from the principal on both transports (no request field names one), and a campaign or coverage call naming another tenant's engagement or campaign is REST's 404, NOT_FOUND in REST's sentence. ⭐ Requests are typed and answers are google.protobuf.Struct: the posture, targets, assets, services and weaknesses get real messages, because a client stub is generated from the request. The five answers are five different shapes, one of them (the self-engagement report) growing a field per scenario, so each is REST's JSON transcoded whole, as the memory analysis is. A reflection guard holds each bridge body's JSON key tree equal to the REST request type's, because the engagement door is strict and a renamed key would otherwise surface as a runtime 400. Integers cross as int64 and are judged only by the door: a negative or over-ceiling ttl_seconds or window is REST's own refusal in REST's sentence. Fleet-rollout twins (2026-10-02c). The update orchestrator's five step-up writes, through the same bridge (conduit/api/grpc_bridge_rollouts.go): CreateRollout, ImportRolloutBundle, PromoteRollout, HaltRollout and RollbackRollout. Parity is now 182 of 369, the twinned floor 182, and /v1/rollouts/ joins the fully-twinned prefixes (create is POST /v1/rollouts, outside the prefix and twinned too; the list and the bundle export stay REST-only reads). Every door is authStepUp(canApprove), reached at the bearer posture. The handlers' rules hold on both transports: a tenant admin's plan is FORCED onto its own tenant, whatever its tenant says; another tenant's rollout id is refused in REST's sentence; and the kill switch refuses create and promote (409, so FAILED_PRECONDITION). ⛔ Two documents ride as JSON bytes: the PLAN (rollout_json) goes verbatim to REST's strict decoder and rollout.Validate — an unknown key is refused as over REST — and the signed BUNDLE (bundle_json) goes verbatim to the fail-closed verifier. The signature covers rollout.Bundle.Canonical (a re-marshal), so the bridge's compaction cannot break a valid one, and a tampered field is refused by name. Absence: an absent halt reason takes REST's operator halt default (halting is the safe direction); an absent id is refused before dispatch. Erasure twins (2026-10-05pl). The right-to-erasure plane's four auth-gated writes, through the same bridge (conduit/api/grpc_bridge_erasure.go): RequestErasure, FulfillErasure, SweepRetention and CryptoShred. Parity is now 272 of 381, the twinned floor 272; GET /v1/erasure stays a REST-only read, so /v1/erasure/ does not join the fully-twinned prefixes. RBAC is REST's: canAct on the request and the sweep, authStepUp(canApprove) on the fulfilment and the shred, both reached at the bearer posture. ⛔ The tenant is never on the wire — all four take the subject from the request and the tenant from the principal, so a client cannot name another tenant's subject; the field does not exist to be set. Absence: an absent subject is REST's own consent.request.subject_missing (400), deliberately not a bridge-invented refusal; an absent fields is the WHOLE-SUBJECT erasure, which is the wider duty, so proto3's inability to distinguish absent from empty can only erase more, never less; an absent grace_seconds is REST's own 0. The obligation document rides as JSON bytes (erasure_json, and the sweep's erasures_json) because a whole-subject and a field-level erasure are different shapes and a protobuf spelling would be a second parser that could drift. The idempotent repeat of a fulfilment is OK with changed false on both transports, never an error. Threat-intel twins (2026-10-02j). All five auth-gated writes under /v1/intel/, through the same bridge (conduit/api/grpc_bridge_intel.go): AddIntelIndicators, RevokeIntelIndicator, IngestIntelFeed, AddIntelFeed and RemoveIntelFeed. Parity is now 193 of 369 (with the posture twins above), the twinned floor 193, and /v1/intel/ joins the fully-twinned prefixes (its three reads stay REST-only). RBAC is canManageIntel — a platform admin only, because the indicator store is matched against every tenant's telemetry; a tenant admin is refused on both transports in REST's sentence. proto3 absence needs no optional here: indicatorRequest.build already reads every option by its zero value (confidence ≤ 0 → 50, empty severity → medium, empty kind → inferred), so an absent field reaches REST as the omission a REST client sends. The batch always crosses as a JSON array, so an empty request is REST's intel.indicators.none; it is all-or-nothing on both transports, and the one refusal that carries data — a bad element, whose REST body names params.index — rides as the rich-error detail IntelIndicatorRefusal{ string message_id = 1; int64 index = 2; string detail = 3 }. ⛔ The feed document crosses verbatim: IngestIntelFeedRequest{ bytes content = 1; string format = 2; string source = 3 } forwards content as the REST door's raw body (a new bridge body kind, twinRawBody, beside the query string), so the door's own STIX/list parser and its format auto-detection are the only ones; REST's 8 MiB bound is 4 MiB on gRPC (the frame cap). RemoveIntelFeed keys by api_root + collection_id, riding as REST's ?api_root=&collection=.

Posture twins (2026-10-02e). All six auth-gated writes under /v1/posture/, through the same bridge (conduit/api/grpc_bridge_posture.go): EvaluateHardening, EnforceHardening, SnapshotPKIPosture, AddDLPDataset, RemoveDLPDataset and SetDLPDatasetEdge. Parity is now 188 of 369, the twinned floor 188, and /v1/posture/ joins the fully-twinned prefixes (its reads stay REST-only). RBAC is REST's: canAct on the hardening and PKI doors, canManageDLP (a Platform admin) on the DLP doors. ENFORCE is proposed through the actions gate on both transports, so the twin adds no way to change a host. ⛔⛔ The DLP edge opt-in's tenant is optional and REQUIRED on the wire. The feed carries the dataset's HMAC salt, and REST reads an omitted tenant as a deliberate estate-wide publication (T1-49). An absent tenant is therefore refused (missing required field: tenant), while an explicit "" still reaches the estate-wide publication: the SetEgressPolicy rule, applied to the second door whose empty tenant means everyone. Absence elsewhere: RemoveDLPDataset.all absent is the NARROW erase. An empty collector document is absent, never a present source that would read as sources_mixed, and confidence absent is 0, as on REST. The collector documents (osquery, inventory, audit policy) ride as JSON bytes so each keeps one parser. The deep answers (the hardening posture, the PKI snapshot) are google.protobuf.Struct; the DLP answers are typed.

Sensor twins (2026-10-01ab). All nine auth-gated writes under /v1/sensor/, through the same bridge (conduit/api/grpc_bridge_sensor.go): the agent transport's RegisterSensor, ReportSensorResult, AckSensorDetection, AckSensorUpdate, RecordSensorSessionChunk and CloseSensorSession (canAgent); the operator's GrantLeaseAuthority / RevokeLeaseAuthority (canApprove + step-up, at the bearer posture); and the tenant admin's SetSensorProfileLeg (canManageConnectors). Parity rose by nine (181 of 369 when authored; 202 of 369 when landed by 2026-10-03g from the abandoned worktree, after 02u/02c/02e/02j), the twinned floor with it, and /v1/sensor/ joins the fully-twinned prefixes (the agent pulls and operator reads stay REST-only). An enrolled device's SVID is a bearer, so it crosses as the credential and the agent binding is REST's on both transports — the tests run a second enrolled device in the same tenant and it may not register, report, ack or record as the first one's agent. No request message names a tenant (AckSensorDetection's REST body type has one; the handler overwrites it, so the twin omits it). ⛔ Absence: SetSensorProfileLegRequest.arm is optional, because REST reads it as a pointer — disarming makes a fleet's telemetry go quiet, so an omitted field is refused, never a silent disarm; an omitted update ok is a FAILED ack that halts the rollout (REST's decode, and the safe side). ⛔ Verbatim documents: the agent's registration rides as JSON bytes (registration_json, one parser — the posture precedent), and the signed artefacts in its answer (feed_key_chain_json, tenant_declaration_json) ride as the REST body's own bytes, never re-spelled. ⛔ Two refusals carry data the agent ACTS on, as rich-error details beside an unchanged status and sentence: SensorWireRange on register's below-floor 409 (unreachable while one wire exists; proven against the body), and SensorSessionTerminated on a chunk's fail-closed self-termination 409 — the session is dead and the agent must stop capturing; the door's not-active 409 carries no flag and gets no detail. A chunk's data is raw bytes on the wire and base64 in the REST body.

Verification twins (2026-10-03w). All six auth-gated writes under /v1/verify/, through the same bridge (conduit/api/grpc_bridge_verify.go): the sold VerifyDocument, the offline Aadhaar legs VerifyAadhaarQR and VerifyAadhaarKYC, the brokered VerifyService, RegisterVerificationConsent (canRunVerification) and EraseVerificationSubject (canApprove + step-up, at the bearer posture). Parity 208 of 369, the twinned floor with it, and /v1/verify/ joins the fully-twinned prefixes (usage, bill, invoice, the catalogue, the consent list and the per-verification read stay REST-only; the ops harness under /v1/apisetu/ is a different prefix). The licence gate, the tier's family, the cap and the tenant (always the principal's — no request names one) are REST's on both transports. ⛔ PII: the share code and the consent subject appear in no answer or refusal on either transport; the tests send distinctive values and grep every returned byte. ⛔ The self-asserted consent flag has no wire field — REST refuses {"consent": true} by name; gRPC cannot express it. ⛔ Uploads keep their door's parser and caps: the document rides as raw bytes (?filename= beside it), the e-KYC ZIP is re-framed as the multipart form its door reads, and the Service document becomes REST's document_base64 under maxServiceBody; the 4 MiB frame is the narrower bound. ⭐ Two refusals carry data as rich-error details: VerificationCap on the cap's 429 (RESOURCE_EXHAUSTED) and VerificationDependency on the Service door's 424, which now maps to FAILED_PRECONDITION (it fell to INVALID_ARGUMENT before; no earlier twin answered 424).

Tenant-admin twins (2026-10-03ad). All six auth-gated writes under /v1/tenant/, through the same bridge (conduit/api/grpc_bridge_tenant.go): CreateTenantInvitation and ResolveAccessRecovery (canIssueCredential + step-up, at the bearer posture), RevokeTenantInvitation, RevokeTenantUser, EndTenantSessions and EndTenantSession (canIssueCredential). Parity 214 of 369, the twinned floor with it, and /v1/tenant/ joins the fully-twinned prefixes (the directory, invitation, session and recovery-queue reads stay REST-only). Every door is TENANT-FORCED from the principal — no request names a tenant — and a foreign id is REST's 404 on both transports. ⛔ One-time secret: the invitation's link (its fragment IS the token) rides on the minting answer only, as on REST. ⛔ No tier field: the tenant door refuses any tier by name, so the wire cannot express it. ⛔ who is never widened by omission: an absent or empty who is not forwarded, and REST refuses it — there is no "sign out everyone". The recovery resolution's two REST answers (a denial's {id, state}, an approval's invitation) ride as the one Invitation message by key.

Policy-plane twins (2026-10-03af). All six auth-gated writes under /v1/policy/, through the same bridge (conduit/api/grpc_bridge_policy.go): SetPolicyDocument and WithdrawPolicyDocument (canManageDetections), CompilePolicy and AssurePolicy (canRead — they write nothing), StagePolicy and ReconcilePolicy (canAct). Parity 220 of 369 (with 03ad's tenant ×6, landed beside it), the twinned floor with it, and /v1/policy/ joins the fully-twinned prefixes (the document list, validation and effect reads stay REST-only). ⛔ No tenant crosses the wire — neither the document nor any target has a tenant field; REST forces both from the principal, and the twin has nothing to discard. The same holds for revision (server-assigned, the replay-ordering key), author, updated_at and withdrawn (the withdraw door is the one way to tombstone). ⛔ The compare-and-swap is REST's: an absent if_match is a first declaration, so on an existing document it is REST's 409 → FAILED_PRECONDITION in REST's sentence — absence never overwrites blind. ⛔ The spec rides as JSON bytes (spec_json, one validator per domain behind the door); an empty one is absent, never a "" the strict decoder would refuse. ⭐ A held or denied wave is REST's 200 with staged:false, so gRPC OK carrying the same answer, and the wave's backend crosses with it — a simulated wave must not read as enforcement on either transport.

Backfill twins (2026-10-03al). All five auth-gated writes of the displacement-migration surface, through the same bridge (conduit/api/grpc_bridge_backfill.go): CreateBackfill, RunBackfill, PauseBackfill and ResumeBackfill (canManageConnectors) and RetroScanBackfill (canAct — it can page). Parity 225 of 369 (with 03af's /v1/policy/ ×6 beside it), the twinned floor with it, and /v1/backfill joins the fully-twinned prefixes — held WITHOUT the trailing slash so the bare create door is inside the claim (the job list and the coverage read stay REST-only). The job rides as job_json bytes (the rollout_json precedent: it embeds a whole federated source); the job's tenant is FORCED from a tenant key and named by a Platform key, exactly as on REST; every answer is REST's redacted job, so a source credential never comes back. slices is REST's ?slices=, forwarded unclamped (absent and 0 both mean "to completion" on both transports; a negative one is REST's 400). ⛔ A failed pull keeps its evidence: REST's 502 carries the persisted job (the cursor is durable and the next run resumes), so the twin answers UNAVAILABLE — 502 now maps there, gRPC's own HTTP table; it fell to UNKNOWN before and no earlier twin answered 502 — with the job as the rich-error detail BackfillRunRefusal{ job=1 }. Detection-compiler twins (2026-10-03am). All four auth-gated writes under /v1/compiler/, through the same bridge (conduit/api/grpc_bridge_compiler.go): CompileDetectionRecommendations and CompileEdgeCandidates (canAct — they author it/forge/recommendation events into the caller's lake), DeployEdgeRecommendation and ApplyDetectionRecommendation (canManageSearches — an enforcing edge push; a conductor feedback-store override). Parity 229 of 369 (with 03al's backfill ×5 beside it), the twinned floor with it, and /v1/compiler/ joins the fully-twinned prefixes (the edge-artifacts read stays REST-only). ⛔ No tenant crosses the wire — every handler reads the principal's tenant and nothing else. ⛔ The window is REST's query, verbatim: since/until are forwarded as the RFC 3339 strings REST's timeWindow parses (and silently ignores when malformed); the twin does not parse them itself, so it can be neither stricter nor laxer than REST. ⭐ A held edge push is REST's 200 with deployed:false and the gate's decision, so gRPC OK carrying the same answer, never an error status. Discover twin (2026-10-10xj). The one auth-gated write at /v1/discover, through the same bridge (conduit/api/grpc_bridge_discover.go): Discover (canAgent — an admin, ingestor or Sensor key). Parity 371 of 398, the twinned floor with it, and /v1/discover joins the fully-twinned list. ⭐ The document is relayed, never re-signed: this process holds no root key, and the answer is REST's signed document — the signature as REST's base64 string — so an agent that verifies over gRPC verifies the bytes REST serves (proved: the gRPC answer verifies against the root). A deployment serving no document is REST's 503, distinct from an empty one. The body (agent_id) is advisory. Wire shape: DiscoverRequest{agent_id=1}; the answer is REST's JSON as a google.protobuf.Struct.

Sensor binding release (2026-10-10xk). The M5 follow-up of the 2026-10-04ai scan: ReleaseAgentBinding — the operator undo of an agent id's first device claim (a renamed or re-imaged host, a reused name, a squatted one handed back) — was a spine method with no surface. It is now DELETE /v1/sensor/bindings/{agent} (conduit/api/agentbinding.go; admin, step-up, the caller's own tenant only) and was born twinned: ReleaseAgentBinding in grpc_bridge_sensor.go. Parity 372 of 399. ⛔ No agent may call it, not even for its own name — the binding exists because an agent's word is not enough. A name holding no binding answers 404 in both transports, so a release that changed nothing never reads as one that did.

The last seven twinnable doors (2026-10-10xn). conduit/api/grpc_bridge_forensics.go: AcquireMemory, Detonate and PreserveArtifact — the image rides as REST's content_base64 string (REST decodes and refuses it), and a preserved artifact's bytes ride as gRPC bytes into REST's raw body, hashed against {sha256} exactly as a curl upload is; the custodian is the caller on both transports. conduit/api/grpc_bridge_analysis.go: Ask, AssessBlindnessDetail, DualRunIncumbent (body plus REST's rules/since/until/tenant query; the incumbent's alerts and rule_map as JSON bytes verbatim, so an NDJSON string export reaches REST unchanged) and PostThreadMessage (the comms thread write; the end-to-end messaging plane is a different, held door). Parity 380 of 400; the 20 left all wait on an owner design. ⚠ Found on the way: the evidence export (GET /v1/evidence/artifact/{sha256}) answered an empty 200 to any gzip-accepting client; it is now exempt from compression.

Full parity — the twenty protocol doors (2026-10-10xo). Released by the owner, conduit/api/grpc_bridge_bodypass.go twins ingest, MCP, WebAuthn registration and revocation, operator passkeys, the sign-in begin, the end-to-end messaging plane, push tokens, query/sql, recovery and both rollout payload uploads as body-pass methods: field 1 is REST's body verbatim (a JSON document refused before REST is asked if it does not parse, or raw bytes for a payload), fields 2… the path and query values. The bridge builds none of these protocols' semantics — REST verifies the WebAuthn ceremony, keeps the sealed message ciphertext, bounds the ingest batch — so a gRPC caller can do exactly what a REST caller can and nothing more. Parity 400 of 400: every auth-gated REST mutation has a gRPC twin, and the ledger now fails on any new door without one.

Parity restored — the nineteen 2026-10-25/26 doors (2026-10-27k, audit G-3 / IN-06). The ledger had drifted to 436 of 455, six gaps inside fully-twinned families, so the "full parity" claim was stale. conduit/api/grpc_bridge_bodypass_forms.go twins the nineteen as body-pass methods (field 1 REST's body verbatim, field 2 the path segment where the door has one; REST's strict decoder, cap and gate stay the only admission): IssueCertificate, RenewCertificate, RevokeCertificate, QueryUQL, PlanSearchIntent, LookupIndicator, FlagCampaign, RedeemPAMProxyTicket, DecideActionLink, AckAlertPage (alert=2), SnapshotNode, ExplainIngest, RevealInsiderIdentity, IssueNewsletter, ImportOSCALCatalog (raw catalogue bytes, name=2), WithdrawOSCALCatalog (id=2), SetOwnEgressPolicy (the tenant's own PUT /v1/egress-policy; SetEgressPolicy is the operator's), AnalyzeDBSecConfig, each answering REST's JSON as google.protobuf.Struct. The one document answer, RenderDossier, returns RenderedDocument{ bytes content=1; string media_type=2; string filename=3; string digest=4 } — REST's bytes and the three headers it sends beside them; a document over the 4 MiB frame is RESOURCE_EXHAUSTED. Parity 455 of 455; the twinned floor is 455.

DLP-scan twin (2026-10-10xi). The one auth-gated write under /v1/dlp/, through the same bridge (conduit/api/grpc_bridge_dlp.go): ScanDLP (canAct). Parity 370 of 398, the twinned floor with it, and /v1/dlp/ joins the fully-twinned list. ⭐ Restricted content leaving is enforced through the ACTION GATE inside the spine (a held or refused quarantine is REST's 200 carrying the gate's status, so gRPC OK); below-Restricted is a no-op. ⛔⛔ The content is sensitive by definition and never stored raw — REST records only the verdict, the bridge logs nothing of it, and the answer carries the tier and a finding COUNT, never the content. ⛔ No tenant crosses the wire: the incident lands in the principal's tenant. ⚠ The 4 MiB frame is narrower than REST's 8 MiB body. Wire shape: ScanDLPRequest{content=1, target=2, channel=3}; the answer is REST's {tier, findings, enforced, status, reason, request_id} as a google.protobuf.Struct.

Diagnose twin (2026-10-10xh). The one auth-gated write at /v1/diagnose, through the same bridge (conduit/api/grpc_bridge_diagnose.go): Diagnose (canAct). Parity 369 of 398 (the denominator moved with 2026-10-10b's operator-offboarding door, twinned by that session), the twinned floor with it, and /v1/diagnose joins the fully-twinned list. ⭐ The request rides as REST's two parts: {service, operation, kind, at, auto} as REST's lenient body (empty = auto) and the window as REST's ?since=/?until= query — diagnoseRequest builds both, as the agent launch does. An estate with nothing to diagnose is REST's 200 with an explicit note, never a 404; auto overrides a named service. ⛔ No tenant crosses the wire. Wire shape: DiagnoseRequest{service=1, operation=2, kind=3, at=4, bool auto=5, since=6, until=7}; the answer is REST's JSON as a google.protobuf.Struct.

CTEM-assess twin (2026-10-10xg). The one auth-gated write under /v1/ctem/, through the same bridge (conduit/api/grpc_bridge_ctem.go): AssessExposureManagement (canAct). Parity 367 of 397, the twinned floor with it, and /v1/ctem/ joins the fully-twinned list. ⭐ The window rides as REST's query (?since= / ?until=, the RFC 3339 strings REST's timeWindow parses — and silently ignores when malformed); REST reads no body. ⛔ No tenant crosses the wire: the cycle fuses the principal's tenant's exposures only. Wire shape: AssessExposureManagementRequest{since=1, until=2}; the answer is REST's funnel as a google.protobuf.Struct.

Resilience-drill twin (2026-10-10xf). The one auth-gated write under /v1/resilience/, through the same bridge (conduit/api/grpc_bridge_resilience.go): RunResilienceDrill (canRunPlatformSuites — the OEM Platform admin; the run writes the one node-wide snapshot every tenant reads). Parity 366 of 397, the twinned floor with it, and /v1/resilience/ joins the fully-twinned list. ⭐ The drills are HERMETIC — each builds and tears down its own fixture, so a gRPC run changes nothing but the snapshot. Wire shape: RunResilienceDrillRequest{}; the answer is REST's drill report as a google.protobuf.Struct.

Node-licence twin (2026-10-10xe). The one auth-gated write under /v1/licence/, through the same bridge (conduit/api/grpc_bridge_nodelicence.go): ImportNodeLicence (canManagePlans — the OEM Platform admin — behind step-up). Parity 365 of 397, the twinned floor with it, and /v1/licence/ joins the fully-twinned list. ⭐ The licence rides as REST's own raw bytes (bytes body, no Content-Type): it is a SIGNED document, so the bytes the vendor signed are the bytes the verifier reads — any re-spelling would break the signature. REST's refusals hold on both transports: a node that cannot hold a licence is 409 (never a silent success), an empty body 400, a refused document (forged, replayed, bound elsewhere) 422 naming its cause. The tenant is the licence's, never the caller's. Wire shape: ImportNodeLicenceRequest{bytes body=1}; the answer is REST's {imported, tenant, licence?} as a google.protobuf.Struct.

Retention-policy twin (2026-10-10xd). The one auth-gated write under /v1/governance/, through the same bridge (conduit/api/grpc_bridge_retention.go): SetRetentionPolicy (canAct — a governance act). Parity 364 of 397, the twinned floor with it, and /v1/governance/ joins the fully-twinned list. ⛔⛔ retain is REQUIRED on the wire (optional string retain = 2 + requireField): on REST retain: "" is the documented REMOVE of the purpose's policy — and retention drives the erasure sweep — so a proto3 client that sent only the purpose would delete a standing policy, the exact defect REST's strict decoder was added to stop. The twin refuses that request by name; an explicit "" is REST's remove. ⛔ No tenant crosses the wire. Wire shape: SetRetentionPolicyRequest{purpose=1, optional string retain=2}; the answer is REST's {policy} as a google.protobuf.Struct.

Legitimacy-verdict twin (2026-10-10xc). The one auth-gated write under /v1/legitimacy, through the same bridge (conduit/api/grpc_bridge_legitimacy.go): RecordLegitimacyVerdict (canAct). Parity 363 of 397, the twinned floor with it, and /v1/legitimacy joins the fully-twinned list. ⭐ The author comes from the credential — adjudicated_by is the principal on both transports, with no field to claim one. ⛔ No tenant crosses the wire: the basis is resolved among the principal's tenant's change records only, so another tenant's record is refused in exactly the words of one that exists nowhere. REST's refusals hold on both — a bad timestamp is 400 naming its field; every substantive refusal (no source, unknown / closed basis, a verdict wider than its basis) is REST's 422, INVALID_ARGUMENT on gRPC. Wire shape: RecordLegitimacyVerdictRequest{basis_id=1, identity=2, resource=3, window_start=4, window_end=5, expires=6, note=7}; the answer is REST's verdict JSON as a google.protobuf.Struct (its 201 is OK).

API Setu conformance twins (2026-10-10xb). Both auth-gated writes under /v1/apisetu/, through the same bridge (conduit/api/grpc_bridge_apisetu.go): ProbeAPISetu and VerifyAPISetuDocument (canOperateVerification — the OEM Platform admin; the ops plane). Parity 362 of 397, the twinned floor with it, and /v1/apisetu/ joins the fully-twinned list. ⭐ The probe's containment is REST's on both transports: probing is OFF until the operator opens egress (503 naming the environment key), a production gateway is refused unless allowed, an uncatalogued path is 400, and a sweep runs only when scope says catalog — never because a client left a field empty; a sweep that stops early is REST's 200 with its stop reason. ⭐ The probe crosses as REST's own JSON document (bytes probe_json — it carries a free map of gateway parameters); the document to verify as RAW bytes with no Content-Type, its name as REST's ?filename=. The harness has no tenant. Wire shapes: ProbeAPISetuRequest{bytes probe_json=1}, VerifyAPISetuDocumentRequest{bytes document=1, filename=2}; every answer is REST's JSON as a google.protobuf.Struct.

Vulnerability-assessment twins (2026-10-10xa). Both auth-gated writes under /v1/vuln/, through the same bridge (conduit/api/grpc_bridge_vuln.go): RunAgentlessAssessment and AssessHostNow (canAct — both write findings). Parity 360 of 397, the twinned floor with it, and /v1/vuln/ joins the fully-twinned list. ⭐ The on-host assessment runs through the ACTION GATE inside the spine: a held or refused assessment is REST's 200 with the gate's status and executed: false, so gRPC OK. ⭐ The sweep's three skip counts stay apart as REST keeps them, and an absent assessment is "never assessed", not a clean host, on both transports. ⛔ No tenant crosses the wire: the sweep covers the principal's tenant's inventory only. REST's refusals — no definitions (409), no host (400), no actuation plane / no covering agent (409) — in REST's sentences. Wire shapes: RunAgentlessAssessmentRequest{}, AssessHostNowRequest{host=1}; every answer is REST's JSON as a google.protobuf.Struct.

Agent twins (2026-10-09xj). Both auth-gated writes under /v1/agents/, through the same bridge (conduit/api/grpc_bridge_agents.go): LaunchAgent (canRead + the D23 query ceiling, then REST's PER-AGENT act gate from the roster's own declaration — an acting agent needs canAct — and the tenant's autonomy ceiling) and SetAgentAutonomy (canSetAgentAutonomy; a tenant admin sets only its own tenant, a Platform key names one under that customer's grant). Parity 358 of 397, the twinned floor with it, and /v1/agents/ joins the fully-twinned list. ⭐ The launch rides as REST's three parts: the agent id as {id}, the window as REST's ?since=/?until= query (the RFC 3339 strings REST's timeWindow parses — the twin parses nothing), and {input, args} as REST's body, args as the JSON object REST decodes (bytes args_json — a per-agent shaping map). Every proposal an acting agent files still parks at the action gate. ⛔ The launch's tenant never crosses the wire. ⚠ A transcript above the 4 MiB frame is INTERNAL over gRPC and served over REST. Wire shapes: LaunchAgentRequest{id=1, input=2, bytes args_json=3, since=4, until=5}, SetAgentAutonomyRequest{max_rung=1, tenant=2}; every answer is REST's JSON as a google.protobuf.Struct.

Tenant-locale twin (2026-10-09xi). The one auth-gated write under /v1/i18n/, through the same bridge (conduit/api/grpc_bridge_i18n.go): SetTenantLocale (canIssueCredential — the tenant admin). Parity 356 of 397, the twinned floor with it, and /v1/i18n/ joins the fully-twinned list. ⛔ The SetMyLocale rule, for the same reason: REST tells absent from "" (localeWrite.Locale is a pointer) — {"locale":""} CLEARS the tenant default and {} is refused — so the twin carries optional string locale = 1: an explicit "" clears, absence is REST's own refusal, never a clear of every person's fallback in the tenant. ⛔ No tenant crosses the wire. The answer is the same MyLocale view SetMyLocale answers (⚠ resolved without an Accept-Language on gRPC; the stored levels are the same). Wire shape: SetTenantLocaleRequest{optional string locale=1} → MyLocale{preference=1, tenant_default=2, locale=3, locale_source=4, repeated shipped=5}.

Approval-resolution twin (2026-10-09xh). The one auth-gated write under /v1/approvals, through the same bridge (conduit/api/grpc_bridge_approvals.go): ResolveApproval (canApprove — the admin — behind step-up). Parity 355 of 397, the twinned floor with it, and /v1/approvals joins the fully-twinned list. ⛔⛔ approve is REQUIRED on the wire (optional bool approve = 2 + requireField): REST reads an omitted approve as false — a REJECTION that consumes the approval and closes the hold — and a proto3 bool's absence is indistinguishable from false, so a client that forgot the field would refuse a held containment it never looked at. The twin refuses it by name instead; an explicit false is REST's rejection. ⭐ Releasing an action-gate hold runs REST's second-approver and machine-hold checks before the approval is consumed — an agent's hold needs a human on a stepped-up SESSION, so it is REST's 403 over gRPC. released stays a separate fact from approved. ⛔ No tenant crosses the wire; another tenant's approval is REST's 404, a decided one REST's 409. Wire shape: ResolveApprovalRequest{id=1, optional bool approve=2, reason=3}; the answer is REST's JSON as a google.protobuf.Struct.

Asset-retirement twin (2026-10-09xg). The one auth-gated write under /v1/assets/, through the same bridge (conduit/api/grpc_bridge_assets.go): RetireAsset (canAct — a correction to an INVENTORY, reversible by the host itself on its next report). Parity 354 of 397, the twinned floor with it, and /v1/assets/ joins the fully-twinned list. ⭐ A retirement is not an erasure — the host leaves the boards and every event that described it stays on the lake; REST's answer says so in its note and the twin carries the sentence. ⭐ The reason rides as REST's optional DELETE body {reason}. ⛔ No tenant crosses the wire: an unknown host and another tenant's host are ONE 404 whose only parameter is the id typed, so the refusal is not an existence oracle on either transport. Wire shape: RetireAssetRequest{id=1, reason=2}; the answer is REST's JSON as a google.protobuf.Struct.

Trust-programme twins (2026-10-09xf). All three auth-gated writes under /v1/trust, through the same bridge (conduit/api/grpc_bridge_trust.go): SetPSIRTProfile and PublishPSIRTAdvisory (canOperateTrustProgram — the OEM Platform admin — behind step-up: the profile is what researchers are promised and an advisory IS the disclosure) and TransitionPSIRTReport (the same role). Parity 353 of 397, the twinned floor with it, and /v1/trust joins the fully-twinned list (the OPEN VDP page, advisories read and anonymous intake are not auth-gated, so never counted). ⭐ The programme has no tenant — it is the vendor's own disclosure programme. ⭐ The profile and the transition are flat and mirror REST's keys (repeated strings for the profile's lists, int64 SLAs refused on the int by REST, a bool safe harbor); the advisory crosses as REST's own JSON document (bytes advisory_json — a float score and an instant; REST re-derives the score from the vector and fills the report's own fields). The lifecycle is the state machine's on both transports: a backwards hop, fixed without the fix version, disclosure before the fix and a second disclosure are REST's 400. Wire shapes: SetPSIRTProfileRequest{repeated contacts=1, policy_url=2, canonical=3, encryption=4, acknowledgments=5, repeated languages=6, repeated scope=7, repeated out_of_scope=8, bool safe_harbor=9, int64 ack_sla_seconds=10, int64 disclosure_sla_seconds=11, advisory_prefix=12}, TransitionPSIRTReportRequest{id=1, to=2, assessment=3, cvss_vector=4, cve=5, cwe=6, fixed_in=7, note=8}, PublishPSIRTAdvisoryRequest{id=1, bytes advisory_json=2}; every answer is REST's JSON as a google.protobuf.Struct (the advisory's 201 is OK).

Enrolment twins (2026-10-09xe). Both auth-gated writes under /v1/enroll, through the same bridge (conduit/api/grpc_bridge_enroll.go): IssueJoinToken and RevokeSVID (canIssueEnrollment — the admin; whoever can authorize a join can revoke it). Parity 350 of 397, the twinned floor with it, and /v1/enroll joins the fully-twinned list (the OPEN enroll / challenge / renew doors are not auth-gated — a joining source has no API identity yet — so they are never counted). ⛔⛔ A join token is a credential and is answered exactly once, in the mint's answer as REST answers it; the bridge keeps and logs nothing of it and no read returns it on either transport. ⭐ REST's mint rules hold on both: ttl_seconds above the ceiling is refused on the int (a wrap could land inside any Duration bound); kind is app | agent and absent is app, the NARROWER credential (an agent token is never minted by omission); an agent-kind mint consults the tenant's agents quota. ⭐ The key id routinely carries : and # — the bridge path-escapes it and REST decodes it, so the revoked id is the id sent. ⛔ No tenant crosses the wire; another tenant's identity is REST's 404. Wire shapes: IssueJoinTokenRequest{source_id=1, key_id=2, int64 ttl_seconds=3, kind=4}, RevokeSVIDRequest{id=1}; every answer is REST's JSON as a google.protobuf.Struct (the mint's 201 is OK).

Tenant-plan twins (2026-10-09xd). Both auth-gated writes under /v1/metering/, through the same bridge (conduit/api/grpc_bridge_meteringplan.go): AssignTenantPlan (canManagePlans — the OEM Platform admin — behind step-up; a plan sets what a tenant is billed) and UnassignTenantPlan (the same role, plus the P0-7 grant check). Parity 348 of 397, the twinned floor with it, and /v1/metering/ joins the fully-twinned list (the plan doors are the family's only mutations). ⭐ The tenant is the SUBJECT of the write, not its scope — the OEM billing a customer — so it crosses the wire as REST's own tenant key (the PUT's body, the DELETE's ?tenant=), never filled from the principal; absent is REST's "tenant missing" on both doors, never a default and never "all". ⚠ REST's assignment makes no grant check while its removal does; the twin inherits that asymmetry rather than adding a gate REST lacks (filed for the owner). Wire shapes: AssignTenantPlanRequest{tenant=1, plan=2}, UnassignTenantPlanRequest{tenant=1} (a query parameter on REST); every answer is REST's JSON as a google.protobuf.Struct.

ADF unit twins (2026-10-09xc). Both auth-gated writes under /v1/adf, through the same bridge (conduit/api/grpc_bridge_adf.go): RegisterADFUnit and DeleteADFUnit (canManageDetections). Parity 346 of 397, the twinned floor with it, and /v1/adf joins the fully-twinned list. ⭐ The unit crosses as the operator's file, byte for byte (bytes body, no Content-Type — REST parses YAML or JSON server-side; a protobuf spelling would be a second dialect of the format). ⭐ The payload-kind authority gate is REST's on both transports: a sigma or yara payload is estate-wide content, so a tenant admin is REST's 403 in REST's sentence and the platform operator files it at tenant ""; a correlation / search payload is the caller's own tenant's. Every gate refusal (parse, lint, one id space, behavioral fixtures) is REST's 400. ⛔ No tenant crosses the wire; another tenant's unit and the estate's pack content are REST's one 404 on removal. Wire shapes: RegisterADFUnitRequest{bytes body=1}, DeleteADFUnitRequest{id=1}; every answer is REST's JSON as a google.protobuf.Struct.

Access-review twins (2026-10-09xb). Both auth-gated writes under /v1/iga, through the same bridge (conduit/api/grpc_bridge_iga.go): OpenAccessReview and DecideAccessReview (canAct — opening a recertification campaign and deciding an item are governance acts; the auditor and the ingestor are REST's 403). Parity 344 of 397, the twinned floor with it, and /v1/iga joins the fully-twinned list. ⭐ Both bodies are flat and mirror REST's keys — the three seconds fields ride as int64 and a negative one reaches REST's strict decoder, which refuses it on the int in its own sentence; absent is REST's 0 (the default 90-day window, no due date, dormant flagging off). ⭐ A revoked decision still passes the action gate — the revoke-entitlement action is proposed on both transports alike, and a HELD revoke is REST's 200 with awaiting-approval, so gRPC OK. ⛔ No tenant crosses the wire: the campaign is opened over the principal's tenant and the reviewer is the principal; another tenant's item is REST's 404 on both. Wire shapes: OpenAccessReviewRequest{name=1, principal=2, int64 window_seconds=3, int64 due_seconds=4, int64 dormant_after_seconds=5}, DecideAccessReviewRequest{id=1, decision=2, justification=3}; every answer is REST's JSON as a google.protobuf.Struct (the campaign's 201 is OK).

ITSM bridge twins (2026-10-09xa). All three auth-gated writes under /v1/itsm, through the same bridge (conduit/api/grpc_bridge_itsm.go): RegisterITSMBridge and DeleteITSMBridge (canManageConnectors — pointing case data at an external desk is a connector decision) and ApplyITSMCallback (canIngest — the desk's least-privilege INGESTOR key; what a write-back may do is bounded by the bridge's StatusMap and the ticket→case link, never by the key). Parity 342 of 397, the twinned floor with it, and /v1/itsm joins the fully-twinned list. ⭐ The bridge crosses as REST's own JSON document (bytes bridge_json — two nested string maps where nil and {} mean different things, behind a strict decoder; the desk credential rides inbound and is never echoed). ⭐ The callback is not a signed webhook — its authenticity is the bearer key (the generic contract's HMAC signs the platform's outbound deliveries), so the twin mirrors REST's flat body key for key. ⛔ The tenant is never the caller's to choose: a tenant admin's bridge and a tenant key's write-back land on the principal's tenant whatever the document or tenant says; only a Platform key may name one, under that customer's grant. Wire shapes: RegisterITSMBridgeRequest{bytes bridge_json=1}, DeleteITSMBridgeRequest{id=1}, ApplyITSMCallbackRequest{bridge_id=1, ticket_id=2, status=3, note=4, actor=5, tenant=6}; every answer is REST's JSON as a google.protobuf.Struct.

Geofence and capture twins (2026-10-09wm). All five auth-gated writes under /v1/geo/fences and /v1/capture, through the same bridge (conduit/api/grpc_bridge_geocapture.go): DeclareGeoFence (canManageDetections) and RevokeGeoFence (the same, behind step-up — removing a fence silences its breach alerts); CaptureArtifact (canIngest — metered ingress, REST's per-tenant byte ceiling consulted on both transports); RegisterYaraRule and DeleteYaraRule (the Platform admin — YARA rules are estate-wide). Parity 339 of 397, the twinned floor with it, and both prefixes join the fully-twinned list. ⭐ The fence crosses as REST's own JSON document (bytes fence_json — float coordinates and a strict decoder that refuses a misspelled lng by name), the YARA rule as raw bytes (bytes body, REST sniffs raw text or a {"source"} envelope, as for Sigma), and the capture mirrors REST's flat body (content_base64). ⛔ No tenant crosses the wire. ⚠ The 4 MiB frame is narrower than REST's 16 MiB capture body, so an artifact above ~3 MiB is captured over REST. Wire shapes: DeclareGeoFenceRequest{bytes fence_json=1}, RevokeGeoFenceRequest{id=1}, CaptureArtifactRequest{content_base64=1, source=2, filename=3, media_type=4, source_event=5}, RegisterYaraRuleRequest{bytes body=1}, DeleteYaraRuleRequest{name=1}; every answer is REST's JSON as a google.protobuf.Struct (the fence's 201 is OK).

Tenant access-grant twins (2026-10-09wl). The three auth-gated writes under /v1/access-grants (the CUSTOMER's half of operator access), through the same bridge (conduit/api/grpc_bridge_accessgrants.go): ApproveTenantAccessGrant and DenyTenantAccessGrant (canIssueCredential — the tenant admin — behind step-up) and RevokeTenantAccessGrant (the same, no step-up: ending access only narrows it). Parity 334 of 397, the twinned floor with it, and /v1/access-grants joins the fully-twinned list (the consent list stays REST-only; the operator's half is already twinned under /v1/platform/). ⛔ The customer's consent is the customer's on both transports: the requesting operator and another tenant's admin are answered as not-found, and the bootstrap Platform key is confined once a named operator exists (REST's 403). An approval may not run longer than the operator asked (REST's 400); absent ttl is the requested duration. ⚠ Named …TenantAccessGrant because RevokeAccessGrant is the operator-side twin's method — two inits writing one key would silently drop a twin. Wire shapes: ApproveTenantAccessGrantRequest{id=1, ttl=2}, DenyTenantAccessGrantRequest{id=1}, RevokeTenantAccessGrantRequest{id=1}; every answer is REST's JSON as a google.protobuf.Struct.

Data-Principal-rights twins (2026-10-09wk). All six auth-gated writes under /v1/consent, /v1/restriction and /v1/correction (the Fiduciary's DPDP / GDPR operator doors), through the same bridge (conduit/api/grpc_bridge_dsr.go), all canAct: GrantConsent, WithdrawConsent, RequestRestriction, LiftRestriction, RequestCorrection, FulfillCorrection. Parity 331 of 397, the twinned floor with it, and the three prefixes join the fully-twinned list (the ledger reads stay REST-only). ⛔ The tenant-level consent rule holds on both transports: a grant whose subject is the tenant, or whose purpose is model-training, decides for the whole customer, so it takes an admin and step-up (an analyst's is REST's 403). A restriction is regime-gated exactly as on REST (GDPR confers it; DPDP is REST's 422 → INVALID_ARGUMENT). ⛔ No tenant crosses the wire. Absent categories is REST's blanket consent — REST's meaning for both an absent key and []. Withdraw and lift take subject and purpose as REST's query parameters. Wire shapes: GrantConsentRequest{subject=1, purpose=2, repeated categories=3, int64 expires_seconds=4}, WithdrawConsentRequest{subject=1, purpose=2}, RequestRestrictionRequest{subject=1, purpose=2, reason=3}, LiftRestrictionRequest{subject=1, purpose=2}, RequestCorrectionRequest{subject=1, field=2, value=3, reason=4}, FulfillCorrectionRequest{subject=1, field=2}; every answer is REST's JSON as a google.protobuf.Struct (the 201s are OK).

Detection-pack twins (2026-10-09wj). All three auth-gated writes under /v1/detectpack/, through the same bridge (conduit/api/grpc_bridge_detectpack.go), all canManagePacks (the PLATFORM admin — a pack is estate-wide content, so a tenant admin is refused like everyone else): InstallDetectPack, UninstallDetectPack, InstallCatalogDetectPack. Parity 325 of 397, the twinned floor with it, and /v1/detectpack/ joins the fully-twinned list (the installed list and catalog shelf stay REST-only). ⭐ The signed pack crosses as REST's own JSON document (bytes pack_json): REST verifies the signature after decoding, so a client sending the bytes it signed gets REST's verdict, and a pack tampered after signing is REST's 400 with nothing installed. REST's pack decoder is not strict and the twin does not tighten it. Wire shapes: InstallDetectPackRequest{bytes pack_json=1}, UninstallDetectPackRequest{id=1}, InstallCatalogDetectPackRequest{id=1}; every answer is REST's JSON as a google.protobuf.Struct.

Scheduled-report twins (2026-10-09wi). All three auth-gated writes under /v1/reports/schedules, through the same bridge (conduit/api/grpc_bridge_reportsched.go): RegisterReportSchedule, DeleteReportSchedule (canManageSearches) and RunReportSchedule (canAct). Parity 322 of 397, the twinned floor with it, and /v1/reports/schedules joins the fully-twinned list (the schedule list stays REST-only). ⛔ The standing-egress rule holds on both transports: a notify schedule mails the tenant's composed data, so arming one — or replacing or deleting an armed id — is admin-only (an analyst's attempt is REST's 403, naming the author), and the recipient must pass the tenant's egress policy. Absent notify is false, the safe side: absence can never arm an egress. ⛔ No tenant or author crosses the wire. Wire shapes: RegisterReportScheduleRequest{id=1, report_id=2, int64 interval_seconds=3, int64 window_seconds=4, bool notify=5, to=6, format=7}, DeleteReportScheduleRequest{id=1}, RunReportScheduleRequest{id=1}; every answer is REST's JSON as a google.protobuf.Struct.

Playbook twins (2026-10-09wh). All three auth-gated writes under /v1/playbooks (the SOAR plane), through the same bridge (conduit/api/grpc_bridge_playbooks.go): RegisterPlaybook, DeletePlaybook (canManageDetections — admin only) and ResumePlaybookRun (canApprove behind step-up — resuming releases a held act step through the conductor's gate). Parity 319 of 397, the twinned floor with it, and /v1/playbooks joins the fully-twinned list (the playbook and run lists stay REST-only). ⭐ The playbook crosses as ONE JSON document (bytes playbook_json), REST's strict body verbatim — a dropped trigger sub-field would be a wildcard, so a protobuf re-spelling would be a second parser where a misspelled key must be refused. ⛔ No tenant or author crosses the wire — REST overwrites both after the decode, so a document naming another tenant or author is ignored, and another tenant's playbook or parked run is REST's 404 on both transports. Wire shapes: RegisterPlaybookRequest{bytes playbook_json=1}, DeletePlaybookRequest{id=1}, ResumePlaybookRunRequest{id=1}; every answer is REST's JSON as a google.protobuf.Struct (the resumed run carries the listing's per-run bound).

Detection-content twins (2026-10-09wg). All eight auth-gated writes under /v1/sigma/, /v1/spl/, /v1/kql/ and /v1/esdsl/, through the same bridge (conduit/api/grpc_bridge_ruleimport.go): RegisterSigmaRule and the six translators TranslateSPL/ImportSPL, TranslateKQL/ImportKQL, TranslateESDSL/ImportESDSL (canManageDetections — admin only), and DeleteSigmaRule (Platform admin — the Sigma detector has no tenant dimension). Parity 316 of 397, the twinned floor with it, and all four prefixes join the fully-twinned list (the rule list and coverage reads stay REST-only). ⭐ The request is REST's body, verbatim (bytes body = 1, forwarded as raw bytes with no Content-Type): these doors accept Sigma YAML or a JSON {"source"} envelope, one strict JSON item or a whole savedsearches.conf / ARM / rules export, and tell them apart by sniffing, so REST's own parser decides on both transports and the YAML and .conf forms need no protobuf spelling. A single-event Sigma rule is platform content — a tenant admin's load of one, directly or as an import's output, is REST's 403 — while a Sigma correlation or a counted search lands on the caller's tenant. ⭐ A strict refusal keeps its query diagnostic: REST's 400 carries a family code a client branches on (2026-10-06j), which gRPC's INVALID_ARGUMENT cannot express, so the twin attaches it with the catalogue id as the rich-error detail eventlake.v1.QueryDiagnostic{code=1, message_id=2} — only when REST sent a code. A lenient bulk import's per-item diagnoses ride inside the OK Struct unchanged. Wire shapes: each content door {bytes body=1}, DeleteSigmaRuleRequest{id=1}; every answer is REST's JSON as a google.protobuf.Struct. ⚠ ES-DSL exports above the 4 MiB frame (REST takes 8 MiB) import over REST or in several calls. Legal-hold twins (2026-10-09wf). Both auth-gated writes under /v1/legal-holds (the preservation plane), through the same bridge (conduit/api/grpc_bridge_legalhold.go): PlaceLegalHold (canManageLegalHolds — admin only) and ReleaseLegalHold (the same, behind step-up — releasing re-arms deletion; a bearer key passes step-up on both transports alike). Parity 308 of 397, the twinned floor with it, and /v1/legal-holds joins the fully-twinned list (the hold book read stays REST-only). ⛔⛔ tenant has presence and is REQUIRED on the wire (optional string tenant = 3): for a Platform principal "" is a NODE-WIDE order that blocks every tenant's erasure, and REST reads an omitted tenant that way, so an absent one is refused ("missing required field: tenant") and a node-wide order is reachable only by sending "" deliberately — the egress-policy / DLP-edge / kill-switch rule, owner question §6ev for REST. A tenant admin's "" or own tenant both place on its own tenant; any other tenant is REST's 403. Hold ids are not namespaced, so a tenant admin's release of another tenant's order (or a node-wide one) is REST's 403 through the ForTenant twin on both transports. The nested scope is spelled flat on the wire and rebuilt into REST's {"scope": {...}} before dispatch; an absent scope is the empty matcher — the wider preservation. Wire shapes: PlaceLegalHoldRequest{custodian=1, reason=2, optional tenant=3, id=4, scope_entity=5, scope_category=6, scope_type=7}, ReleaseLegalHoldRequest{id=1}; every answer is REST's JSON as a google.protobuf.Struct (the 201 is OK). Egress and federation twins (2026-10-09we). All four auth-gated writes under /v1/forwarders and /v1/fedsources, through the same bridge (conduit/api/grpc_bridge_egress.go), all canManageConnectors (ADMIN only — a forwarder is how a customer's events leave the platform): RegisterForwarder, DeleteForwarder, RegisterFedSource, DeleteFedSource. Parity 306 of 397, the twinned floor with it, and both prefixes join the fully-twinned list (held without a trailing slash; the list reads, the forwarder templates and the federated search stay REST-only). ⭐ Each config crosses as ONE JSON document (bytes forwarder_json / bytes source_json), REST's strict body verbatim, so a misspelled key can never register a pipe that forwards more than its author meant; the sink bound (the tenant's egress policy) judges the destination on both transports. ⛔ REST's tenant rule, exactly: a tenant admin is forced onto its own tenant whatever the document names; a Platform principal's document may name one (or "" — the all-tenant uplink / platform-wide source). ⛔ Secrets are write-only on both transports — the signing secret, mask key, auth headers and source bearer travel inbound only; no answer echoes one. The forwarder delete's tenant is REST's ?tenant= query parameter, honoured only for a Platform principal (_default names the all-tenant uplink); a Platform delete of a shared id with no tenant is REST's 409, refused rather than guessed, on both transports. Wire shapes: RegisterForwarderRequest{bytes forwarder_json=1}, DeleteForwarderRequest{id=1, tenant=2}, RegisterFedSourceRequest{bytes source_json=1}, DeleteFedSourceRequest{id=1}; every answer is REST's JSON as a google.protobuf.Struct. Detection-tuning twins (2026-10-09wd). All six auth-gated writes under /v1/correlations, /v1/thresholds and /v1/exceptions, through the same bridge (conduit/api/grpc_bridge_tuning.go), all canManageDetections (ADMIN only — each changes what fires for the whole tenant): RegisterCorrelation, DeleteCorrelation, RegisterThresholdOverride, DeleteThresholdOverride, RegisterException, DeleteException. Parity 302 of 397, the twinned floor with it, and the three prefixes join the fully-twinned list (held without a trailing slash; the list reads stay REST-only). ⭐ The correlation rule crosses as ONE JSON document (bytes rule_json), REST's body verbatim, never a protobuf spelling of the stage/predicate tree: REST's strict decoder reaches into every stage, so a misspelled key is REST's 400 in REST's sentence on both transports, and an absent document is JSON null — REST's own refusal. The override and the exception are flat and cross field by field; expires is an RFC 3339 string REST's own time decoder parses. ⛔ No tenant crosses the wire — each register forces it from the principal (and correlate.Rule has no tenant key, so the document cannot smuggle one); a delete removes only the caller's own row (another tenant's, or a platform-wide one, is REST's 404). Absence never weakens a gate: an override's absent threshold is 0 (refused, ≥ 1) and its absent rule is REST's rule_missing; an exception's absent expires is the zero instant (refused — no standing suppressions). Wire shapes: RegisterCorrelationRequest{bytes rule_json=1}, RegisterThresholdOverrideRequest{id=1, rule=2, int64 threshold=3, expires=4, reason=5, approver=6}, RegisterExceptionRequest{id=1, rule=2, repeated entities=3, severity=4, expires=5, reason=6, approver=7}, and each delete {id=1}; every answer is REST's JSON as a google.protobuf.Struct. Saved-search and saved-hunt twins (2026-10-09wc). All six auth-gated writes under /v1/searches and /v1/hunts, through the same bridge (conduit/api/grpc_bridge_searches.go): RegisterSearch, DeleteSearch, SaveHunt, DeleteHunt (canManageSearches — admin or analyst) and RunSearches, RunHunt (canAct; the hunt run also under REST's query limiter, the same bucket on both transports). Parity 296 of 397, the twinned floor with it, and both prefixes join the fully-twinned list (held without a trailing slash — each save is the bare POST; the two list reads stay REST-only). ⛔ No tenant or author crosses the wire: a search or hunt belongs to the caller's tenant, another tenant's (or a platform-wide search) is REST's 404 on delete and run, and a hunt RUN executes the stored SQL over the CALLER's lake — the shelf carries a question, never an answer. Both save bodies are strict on REST; the bridge forwards only the declared keys, so an unknown protobuf field is skipped, never forwarded into a 400. Absent threshold/window/throttle are REST's 0; a search with no predicate field is REST's search.predicate_missing, and a hunt with no sql is REST's refusal. Wire shapes: RegisterSearchRequest{name=1, category=2, type=3, activity=4, entity=5, technique=6, text=7, min_severity=8, int64 threshold=9, int64 window_seconds=10, int64 throttle_seconds=11, severity=12, group_by=13}, DeleteSearchRequest{name=1}, RunSearchesRequest{}, SaveHuntRequest{name=1, sql=2, description=3, repeated tags=4}, DeleteHuntRequest{name=1}, RunHuntRequest{name=1}; every answer is REST's JSON as a google.protobuf.Struct (a hunt result above the 4 MiB message cap is RESOURCE_EXHAUSTED). Deception twins (2026-10-09wb). All five auth-gated writes under /v1/honeytokens and /v1/deception/ through the same bridge (conduit/api/grpc_bridge_deception.go), all canManageDeception (admin or analyst): PlantHoneytoken, RemoveHoneytoken, RotateHoneytoken, PlantDecoy and RetireDecoy. Parity 290 of 397, the twinned floor with it, and both prefixes join the fully-twinned list (/v1/honeytokens without a trailing slash — the plant is the bare POST; the token list, decoy list and GET /v1/deception report stay REST-only reads). ⛔ No tenant or proposer crosses the wire — every handler forces the tenant from the principal, so another tenant's token or decoy is REST's 404 on both transports. An absent honeytoken value is REST's documented GENERATED decoy (fake by construction either way); an absent decoy name is REST's credentials. ⭐ The decoy placement runs the action gate: REST's 201 (planted) and 202 (held — the request id is in outcome) are gRPC OK; the gate's REFUSAL is REST's 403 carrying {decoy, outcome} and no error sentence, so the twin answers PERMISSION_DENIED whose message is the gate's own reason and attaches REST's whole answer as the rich-error detail eventlake.v1.PlantDecoyRefusal{google.protobuf.Struct answer = 1} — the bridge's new opt-in refusalMessage hook, which names words REST already sent and is set on this one twin only. An RBAC or licence 403 is unchanged (sentence, no detail). Wire shapes: PlantHoneytokenRequest{kind=1, value=2, label=3, severity=4, flavor=5}, RemoveHoneytokenRequest{id=1}, RotateHoneytokenRequest{id=1}, PlantDecoyRequest{agent=1, name=2, flavor=3}, RetireDecoyRequest{id=1}; every answer is REST's JSON as a google.protobuf.Struct. Collection-gap owner twins (2026-10-09wa). All four auth-gated writes under /v1/collect/ (§13 row 147: who is fixing a collection gap, under which ticket), through the same bridge (conduit/api/grpc_bridge_collect.go): SetCollectGapOwner and ClearCollectGapOwner (canAct — one host's insufficient-privilege decline) and SetCollectCellOwner and ClearCollectCellOwner (tenant admin — one conditional cell of the verb × platform matrix, fleet-wide). Parity 285 of 397 (281 measured before it: six earlier twins sat above the 275 floor unratcheted), the twinned floor with it, and /v1/collect/ joins the fully-twinned prefixes (coverage, verbs, cell-owners and results stay REST-only reads). ⛔ No tenant or writer crosses the wire — both handlers file under the principal's tenant and stamp the principal as set_by, so another tenant's host is REST's 404 not_assessed and another tenant's record REST's 404 on both transports. agent, verb and platform are REST's path segments (path-escaped; absent ⇒ the bridge's own INVALID_ARGUMENT naming the field); an absent owner or ticket is REST's collect.gap.owner_invalid. The cell refusal's reach is in its sentence, so no rich-error detail. Wire shapes: SetCollectGapOwnerRequest{agent=1, verb=2, owner=3, ticket=4}, ClearCollectGapOwnerRequest{agent=1, verb=2}, SetCollectCellOwnerRequest{verb=1, platform=2, owner=3, ticket=4}, ClearCollectCellOwnerRequest{verb=1, platform=2}; every answer is REST's JSON as a google.protobuf.Struct. Right-to-erasure twins (2026-10-06c). All four auth-gated writes under /v1/erasure (the DPDP §12 / GDPR Art 17 obligation ledger), through the same bridge (conduit/api/grpc_bridge_erasure.go): RequestErasure and SweepRetention (canAct — admin or analyst) and FulfillErasure and CryptoShred (step-up canApprove — admin only; the analyst who may record an obligation may not close one, and a bearer key passes step-up on both transports alike). Parity 275 of 384 (measured 269 of 382 at the session's base; 05p's two MFA enrolment twins landed beside it), the twinned floor with it, and /v1/erasure joins the fully-twinned prefixes — held without a trailing slash, since the request is the bare POST /v1/erasure (the ledger read stays REST-only). ⛔ No tenant crosses the wire — every handler forces the owning Fiduciary from the principal, so another tenant's obligation is REST's 404 (fulfil) or 409 (shred) on both transports. A legal hold refuses the fulfilment and the shred in REST's 409 sentence, which names the order — the hold id is in the sentence, so no rich-error detail is added (as on EraseLead, the verify erasure and the key-root doors). fields absent or empty is REST's whole-subject erasure; grace_seconds absent is REST's documented grace 0, and a negative one crosses intact and is refused on the int. Wire shapes: RequestErasureRequest{subject=1, reason=2, repeated fields=3}, FulfillErasureRequest{subject=1}, SweepRetentionRequest{int64 grace_seconds=1}, CryptoShredRequest{subject=1, reason=2}; every answer is REST's JSON as a google.protobuf.Struct, the request's 201 gRPC OK. Grievance-redressal twins (2026-10-04n). All four auth-gated writes under /v1/grievance (the DPDP §13 queue), through the same bridge (conduit/api/grpc_bridge_grievance.go): RaiseGrievance, AcknowledgeGrievance, ResolveGrievance and SweepGrievances (all canAct — the Fiduciary's data-protection function; auditor and ingestor refused). Parity 267 of 371 (with 04h's /v1/sso/, 04l's /v1/leads/ and 04m's /v1/keyroots ×4 each beside it), the twinned floor with it, and /v1/grievance joins the fully-twinned prefixes — held without a trailing slash, since the raise is the bare POST /v1/grievance (the queue read stays REST-only). ⛔ No tenant crosses the wire — every handler forces the owning Fiduciary from the principal, and acknowledge/resolve answer another tenant's grievance as an unknown one (one 404). The raise's subject is REST's own operator-door key (the Fiduciary records a Data Principal's complaint in its own queue); the principal's self-service filing is POST /v1/me/rights, twinned under /v1/me/. An absent sla_seconds is REST's regime default; a negative one crosses intact and is refused on the int. Wire shapes: RaiseGrievanceRequest{subject=1, category=2, summary=3, int64 sla_seconds=4}, AcknowledgeGrievanceRequest{id=1}, ResolveGrievanceRequest{id=1, resolution=2}, SweepGrievancesRequest{}; every answer is REST's JSON as a google.protobuf.Struct, the raise's 201 gRPC OK. Lead-plane twins (2026-10-04xb). All four auth-gated writes under /v1/leads/ (the OEM's own CRM), through the same bridge (conduit/api/grpc_bridge_leads.go): UpdateLead, AddLeadActivity and ConvertLead (canProvisionTenants — the Platform flag; a prospect is not a tenant, so a customer tenant's own admin is refused every door) and EraseLead (canProvisionTenants + step-up — it destroys the lead's data key). Parity 259 of 371 (with 04h's /v1/sso/ ×4 beside it), the twinned floor with it, and /v1/leads/ joins the fully-twinned prefixes. ⛔ The open demand intake is not twinned — POST /v1/leads/demo is the marketing form's door with no platform principal (openroutes.go), so the ledger never counts it. ⛔ No actor crosses the wire — every timeline line and the erasure receipt name the caller. UpdateLead stays partial (REST's pointers): an absent field never reaches REST; its four fields are optional string so a stock client can send an explicit "" (clear an owner). ⭐ The conversion carries an invitation, never a key, and its idempotency 409 keeps the tenant the lead already points at as the rich-error detail LeadAlreadyConverted{ tenant=1 } under FAILED_PRECONDITION. Key-root twins (2026-10-04m). All four auth-gated writes under /v1/keyroots (the W42 BYOK/HYOK ceremonies), through the same bridge (conduit/api/grpc_bridge_keyroots.go): EnrollKeyRoot, RotateKeyRoot, RevokeKeyRoot (the PROPOSAL — a conductor HOLD, gRPC OK carrying Status: awaiting-approval and the RequestID) and ApproveKeyRootRevoke, all canApprove + step-up exactly as REST. Parity 263 of 371, the twinned floor with it, and /v1/keyroots (no trailing slash, so the bare enroll door is inside the claim) joins the fully-twinned prefixes. ⛔ No wire field widens scope — the three body doors' tenant is REST's own key, honoured for a Platform principal only (who must name one); every other caller's ceremony is forced onto its own tenant, and another tenant's held revocation is one 404 on both transports. BYOK material rides as REST's base64 text, judged by REST's decoder; no answer echoes it. OIDC-provider twins (2026-10-04i). All four auth-gated writes under /v1/oidc/, through the same bridge (conduit/api/grpc_bridge_oidc.go): RegisterOIDCClient (canManageOIDC + step-up — a relying party receives signed identity tokens for the tenant's users), AuthorizeOIDC and ApproveOIDCAuthorization (canReadSelf + step-up — each mints a code), DenyOIDCAuthorization (canReadSelf). Parity 251 of 371, the twinned floor with it, and /v1/oidc/ joins the fully-twinned prefixes. ⛔ The open protocol doors are not twinned — POST /v1/oidc/token and GET|POST /v1/oidc/userinfo (with the reads jwks and the GET authorization endpoint) are the RFC 6749 / OIDC Core contract a relying party speaks with no platform principal; they are not auth-gated, so the ledger never counts them. ⛔ No subject crosses the wire — both minting doors sign in the CALLER; the registration's tenant key is REST's own (Platform principals only). max_age is optional int64, so an explicit 0 reaches REST as 0. The registration's 201 is gRPC OK, its one-time client_secret on that answer exactly as REST shows it once. Enterprise-SSO twins (2026-10-04ab). All four auth-gated writes under /v1/sso/ (review §7a W41), through the same bridge (conduit/api/grpc_bridge_sso.go): RegisterSSOIdP and IssueSCIMToken (canManageSSO + step-up — an IdP trust decides who logs in as whom; a provisioning token drives every operator's lifecycle), DeleteSSOIdP and RevokeSCIMToken (canManageSSO, under their removesAuthorityOnly waivers). Parity 255 of 371 (with 04i's OIDC ×4 landed beside it), the twinned floor with it, and /v1/sso/ joins the fully-twinned prefixes (the IdP, directory and token reads stay REST-only; the unauthenticated login/callback/SAML ACS/metadata doors are an IdP's browser bindings, outside the auth-gated ledger). ⛔ A tenant reaches the handler only as REST's own body key, which it overwrites for a tenant key and honours for a Platform key — the twin reads neither. The register's answer carries REST's enforce_sso break-glass warning verbatim; the mint's raw token is in that answer only, as on REST. Lawful-monitoring twins (2026-10-03ax). All four auth-gated writes under /v1/monitoring/ (O4, GDPR Art. 88), through the same bridge (conduit/api/grpc_bridge_monitoring.go): SetMonitoringProfile and ApproveMonitoringReveal (canApprove + step-up — the works-council boundary and the second pair of eyes), RequestMonitoringReveal and DenyMonitoringReveal (canAct). Parity 245 of 369 (with 03ao's wallet ×4 landed beside it), the twinned floor with it, and /v1/monitoring/ joins the fully-twinned prefixes (the profile, notice and reveal-queue reads stay REST-only). ⛔ No tenant and no actor cross the wire — the requester / approver / denier is the caller's key, so the ledger's self-approval refusal (four-eyes) holds on both transports. An absent profile_json is REST's empty declaration, which REST refuses (monitoring.profile.purposes_missing) — nothing is wiped by omission. The request's 201 is gRPC OK. Own-model pilot twins (2026-10-03ar). All four auth-gated writes under /v1/model/, through the same bridge (conduit/api/grpc_bridge_model.go): CutModelCorpus, CanaryModel, RegisterModel and PromoteModel (all canManageSearches — a model is served content, the detection-author trust tier). Parity 237 of 369 (with 03aq's exposure ×4 beside it), the twinned floor with it, and /v1/model/ joins the fully-twinned prefixes (the registry and AI-BOM reads stay REST-only). ⛔ No tenant crosses the wire. ⛔ No egress is widened (owner §6lp): the canary and the promote grade only the models the operator wired at boot, reached through REST's handler with the caller stamped on the LLM call scope; register's provider/endpoint are registry metadata, never dialled. ⭐ An eval-gate refused promotion is REST's 200 with ok:false, refused:true and the reason, so gRPC OK carrying the same answer; the canary with no candidate wired is REST's 409, so FAILED_PRECONDITION. Wallet twins (2026-10-03ao). All four auth-gated writes under /v1/wallet/, through the same bridge (conduit/api/grpc_bridge_wallet.go): IssueWalletCredential (canIssueCredential + step-up) and RevokeWalletCredential (canIssueCredential), PresentWalletCredentials (canReadSelf + step-up — the holder's own act) and VerifyWalletPresentation (canRead — the relying party). Parity 241 of 369 (with 03al backfill ×5, 03am compiler ×4, 03aq exposure ×4 and 03ar model ×4 landed beside it), the twinned floor with it, and /v1/wallet/ joins the fully-twinned prefixes (the holder's credential list and the status list stay REST-only reads). ⛔ No tenant and no holder cross the wire — the holder of a presentation is the principal's own subject, so a colleague in the same tenant cannot present someone else's credential on either transport; user on issue is REST's own subject field inside the issuer's tenant. ⛔ SSO: the twin adds no authentication of its own and forwards no cookie; step-up holds at the bearer posture a REST key already has. ⛔ A failed verification is an answer, not a refusal — REST's 200 valid:false is gRPC OK with valid absent and the same reason and trusted_issuers; an omitted audience or nonce is still REST's 400 (the challenge is required, O9 s3-197). The issue answer carries the credential JWT as REST's 201 does and never the SD-JWT disclosures, which stay holder-side; the claims ride as JSON bytes (claims_json), an empty one absent.

Exposure twins (2026-10-03aq). All four auth-gated writes of the external attack-surface surface, through the same bridge (conduit/api/grpc_bridge_exposure.go): ScanExposure and WatchExposureBrand (canAct — findings can page), DeclareExposureScope (canManageDetections) and VerifyExposureScopeAsset (canApprove). Parity 233 of 369 (with 03am's /v1/compiler/ ×4 beside it), the twinned floor with it, and /v1/exposure/ joins the fully-twinned prefixes (the board, scan-status and scope reads stay REST-only). Every door is tenant-forced on both transports and no request message carries a tenant. ⛔ No side door to the network: an empty scan/watch request forwards REST's empty body, so it drives the SAME wired scanner / watch source over the tenant's ownership-VERIFIED scope through the same handler — the bridge adds no dialler and cannot widen the scope. The imported surface, observations and scope ride as JSON bytes (surface_json → REST's {"surface": …}, observations_json → {"observations": …}, scope_json → the whole body). ⛔ An ABSENT scope_json is refused before dispatch ("missing required field: scope_json") — the declare REPLACES the scope, so omission would otherwise wipe every verified asset; an explicit {} still reaches REST's empty declare.

2.2.1 Why not grpc-go — the rejected alternative, recorded

The hard constraint (D5 two-tier doctrine) is that conduit/ is stdlib-only — zero go.mod requires. Importing google.golang.org/grpc + google.golang.org/protobuf would:

  1. Break the zero-require core. It would have to live in a separate module (the conduit-perf/conduit-mobile precedent), splitting the management plane across modules for one transport.
  2. Pull a large transitive tree — golang.org/x/net, x/sys, x/text, google.golang.org/genproto — each needing SPDX-allowlist vetting and a measured D4/capability justification to enter.
  3. Need the protoc codegen toolchain (protoc + protoc-gen-go + protoc-gen-go-grpc) in the build.

gRPC is not a framework we must adopt; it is three public specs the substrate already covers:

Layer Spec What we use
Transport HTTP/2 net/http already speaks h2 server-side over TLS (ALPN) — stdlib
Framing gRPC length-prefixed-message [1-byte compressed-flag=0][4-byte big-endian length][bytes]
Status gRPC over HTTP/2 a grpc-status (+ optional percent-encoded grpc-message) HTTP/2 trailer
Payload protobuf wire format varint tags + length-delimited fields — a small hand-rolled codec

A clean-room implementation is exactly this project's ethos (it hand-rolls Modbus / ENIP / PROFINET / DNS / Sigma / STIX / ARP). Because it emits the actual bytes on the wire, it interoperates with REAL gRPC clients (grpcurl, grpc-go, grpc_health_probe).

2.2.2 The security posture — identical, reused verbatim

Every Command WRITE is dispatched THROUGH its REST twin's route (grpc_bridge.go, 2026-09-26yu). The protobuf request becomes the exact REST request (method · path with ids escaped into one segment each · JSON body), carrying only the caller's credential and forwarding headers, served by the real REST handler — serving shell, mux, s.auth, handler — and the answer is transcoded back: a 2xx into the protobuf response, a refusal into the mapped gRPC status (400/413/422 → INVALID_ARGUMENT, 401 → UNAUTHENTICATED, 403 → PERMISSION_DENIED, 404 → NOT_FOUND, 409 → FAILED_PRECONDITION, 429 → RESOURCE_EXHAUSTED, 503 → UNAVAILABLE) with the REST body's catalogued sentence byte-for-byte as grpc-message. ⛔ Why: the first two writes carried a HAND gate (lookupKey + one role predicate), and s.auth is far more than the role — the tenant exit gate, bootstrap confinement, placement, the product-line + feature licences, the principal's licence-bound grants, and on success the it/audit/admin record. The hand gate had none of them, so an offboarding tenant's key could propose an action over gRPC that REST refused, and no admin-act record said it happened. No cookie is forwarded (the surface stays bearer-only), so step-up holds the same bearer posture a REST key call has.

The reads followed (2026-09-26yu-ii). GetEvents · GetAlerts · GetCases · Aggregate · ListReports · GetReport and Command/ListPending are bridged the same way, so they now carry what their hand gate lacked: the per-tenant query ceiling (queryLimited → RESOURCE_EXHAUSTED), the tenantless-key refusal, placement, the licences and grants. The REST read is paged, so its twin is too: GetEventsRequest.cursor = 15 → GetEventsResponse.next_cursor = 3. The six streams (Tail*/Watch*) have no REST route to dispatch through, so each is gated through its REST read (grpcStreamGates): s.auth runs over that GET exactly as it would for the read, and the stream proceeds with the principal it admitted. A stream keeps its own ceiling (acquireStream), not the query gate — a subscription held for a connection's life would starve the tenant's scans.

Every RPC resolves the Bearer key from the authorization metadata header (an HTTP/2 header) through the same KeyStore.lookup → Principal; canRead/canAct/canApprove gate exactly as REST; the tenant is FORCED from the principal, never the request; and assembly.View masks by clearance and records the access audit. Two honest notes the source records and this volume keeps:

2.2.3 Streaming — one wire, three shapes

Server streaming is the same wire as unary; it just keeps the response open: one request frame in, then many [flag=0][4-byte len][message] response frames (one per live event, each flushed immediately via net/http's HTTP/2 Flusher), and finally the grpc-status trailer. A limit-capped tail closes with an OK (0) trailer; a client that disconnects mid-stream simply ends the HTTP/2 stream (the half-close cancels the request context, which stops the subscription) — no trailer is delivered on a broken connection, exactly as real gRPC behaves. The subscription is registered before the 200 headers are flushed, so a client that has received headers is guaranteed its tail is already attached (no startup race).

On the tails, one request field is reinterpreted: limit means "stop the tail after N events" (0 = stream until the client disconnects), not a page size — a live subscription has no page. The subscription is Lossy: a slow tail client sheds through a bounded handoff channel rather than ever back-pressuring ingest. The platform hot path always wins over a lagging consumer.

Bidirectional (WatchEvents/WatchAlerts/WatchCases) is the same wire, now full-duplex over the one HTTP/2 stream: the client may write additional GetEventsRequest frames at any time, each RE-NARROWING the live filter (the canonical "watch" pattern, cf. etcd's Watch). The handler runs a dedicated reader goroutine, but the main goroutine owns every write to the response and every subscription swap, so a frame write never races the lake dispatch path. On a re-narrow the handler stops the old subscription and opens a new one feeding its own bounded channel — any event still in flight for the prior filter is dropped, never mixed into the new filter's stream. A client CloseSend is the client saying it is done sending, not receiving: the server keeps tailing the current filter.

Crucially, the tenant is always forced from the principal and (for the category twins) the category is re-pinned on EVERY command, so no re-narrow — initial or later — can widen past the caller's tenant or the server-forced category. The limit is fixed by the initial request; a re-narrow changes only the filter, never the cap.

2.3 The analyst query surface — SQL over the lake

Status: DECIDED + BUILT (v1, 2026-07-16). The Review-#2 O6 item — "no analyst query language is specified anywhere; migrating analysts (W39) ask on day 1 — either spec the hunt grammar or RECORD the deliberate bet that oracle NL + structured reads + saved queries replace one" — is resolved by the owner: the grammar wins, and it is SQL. Engine conduit/query (clean-room, stdlib-only); surface POST /v1/query/sql.

2.3.1 Why SQL, and what was rejected

Deliberate v1 scope bet: a single-stream SELECT subset — no JOINs, no subqueries, no window functions. The lake is ONE stream ("everything is an event"), so the join-shaped questions EventLake answers are served by purpose-built reads (worldline lineage, correlation searches, /v1/aggregate). The SQL surface is the ad-hoc hunt tool, not a replacement for the platform's read-models. Widen only on measured analyst demand.

2.3.2 Where the boundaries live (unchanged, by construction)

The engine is a LEAF — a compiler + executor over events the CALLER fetched:

Boundary Where it stays
Tenancy forced onto the lake scan from the principal; tenant rejected at compile time
Masking + access audit events flow through the SAME assembly.View read as /v1/events
Push-down vs correctness Pushdown() derives lake-scan hints from top-level AND conjuncts (category/type/domain/activity/id equality, time bounds, min-severity); the FULL expression still post-filters every event — push-down is an optimization, never the boundary (the fedsearch doctrine)
Hostile input 64 KiB statement cap · 100-deep nesting cap · 1k IN-list cap · errors, never panics
Resource bounds 50k-event scan cap (declared scan_truncated) · 10k-group cap (declared truncated) — the no-silent-caps doctrine
Trust every result echoes the compiled plan (push-down summary + residual post-filter)

2.4 Plugin authoring — the third-party decoder path

Author · sign · package · run sandboxed. This is the mechanism that makes any closed, proprietary or OEM wire format integrable without its code, its licence, or its bugs ever entering the permissive stdlib-only core. The whole path is proven end-to-end by a real externally-authored module: sdk/wasm-guest-rust, a zero-dependency Rust crate compiled with a stock toolchain and driven through signing → verification → packaging → the conformance battery → sandboxed execution (conduit-perf/wasmrt/realguest_test.go).

Audience: an OEM, partner or customer engineer with the protocol spec in hand. They write ordinary Rust (or anything that emits import-free wasm); they never touch EventLake source.

2.4.1 The model

A plugin is a pure-compute WebAssembly module: raw protocol bytes in, canonical-event JSON out. It imports nothing — no WASI, no host functions, no network, no disk, no clock. The host (wazero, a pure-Go sandbox) feeds it one frame per call, enforces a memory + wall-clock budget, parses the JSON it returns, and stamps provenance itself. A KMS-signed descriptor binds the plugin's identity, capabilities and budget to the SHA-256 of the exact module bytes — a tampered module, a tampered descriptor, or a revoked/unknown signing key is refused before the module ever loads.

The sandbox makes every plugin safe to run (hostile input, arbitrary licence); the signature makes it safe to trust (provenance, integrity).

Trust tiers are operator-facing metadata — the gate is always the signature plus the deployment's policy:

Tier Who writes it Where it runs Licence posture
first-party (EventLake-built) first-party, clean-room in-core (open spec) or a first-party signed plugin permissive, SPDX-gated
partner-certified OEM or vendor contributes, EventLake certifies signed plugin, fleet-distributed via Rules packs partner's licence, isolated by the sandbox
customer-private the customer's own engineers signed plugin, never leaves their tenant the customer's concern entirely

2.4.2 Rules that follow from the host's behavior

2.4.3 The conformance gate

connector/sdk.Verify is the same gate every EventLake built-in passes, and what a partner submission must pass: manifest validation, decoder construction, a MANDATORY hostile-input battery (empty, truncated, garbage and oversized frames must error-or-decode, never panic; every decode is recover-guarded so a panicking decoder is a structured issue, not a crashed run), and per-sample canonical-event assertions. Report.OK() is the CI gate; sdk.NeverPanics(dec, raw) is the one-line Go fuzz-gate body. Every decoder-bearing built-in connector passes its own battery — the dogfood conformance test.

2.5 GenAI & agentic integration

Status: SHIPPED — the full P0→P4 build order is in-repo (P0 Ollama adapter · P1 embeddings+RAG · P2 MCP server · P3 agent runtime + MCP client · P4 own-model pilot pipeline). The only remaining work is OUT of repo: the LoRA/QLoRA training run + GPU serving (§2.5.9), plus the live external-endpoint smoke tests. This is the HOW companion to the AGI doctrine's WHY (00-charter-and-strategy.md#agi-doctrine — invariants, autonomy ladder, the "substrate is the moat" thesis). That doctrine changes no code; this section names the exact seams a model plugs into.

The one-sentence summary: EventLake already ships the socket — grounded-and-cited answers, a propose-never-execute action path, AI-telemetry decoding, and three arms that watch AI itself; GenAI capability is a model file and an adapter, never an architecture change.

2.5.1 The sockets — what is already BUILT

No model ships with EventLake, deliberately. Bring Claude, a local model, an air-gapped model — EventLake governs whichever, identically. What ships is the seam plus a deterministic default, so the platform works — and every test passes — with no LLM at all.

Seam Where Contract Default when no model
assistant.LLM conduit/assistant Name() string · Complete(ctx, prompt) (string, error) Extractive answers (computed facts + citations only — cannot hallucinate)
assembly.WithAssistantLLM conduit/assembly Injects the model into the assembled platform —
GET /v1/ask conduit/api NL question → assistant.Answer (compiled query + count + summary + citations + suggestions), tenant-forced, RBAC canRead Works today, extractively
assistant.Plan conduit/assistant Clean-room NL → store.Query (no model involved — deterministic intent parsing) Always on
assistant.Suggest conduit/assistant Proposes a containment as a Suggestion — never executes; execution is POST /v1/actions → the conductor gate Always on
vector.Embedder conduit/vector Embed(text) Vector behind the C11 incident-recall HNSW index HashEmbedder (feature hashing — deterministic, air-gap-safe)
decode/genai conduit/decode/genai LLM-gateway telemetry → five canonical types it/ai/{llm-request, tool-call, agent-message, agent-lifecycle, hitl} (producer-alias tolerant: OpenAI/Anthropic shapes, agent frameworks, MCP gateways) —
llmsec / aispm / agentsec three analytics arms (V02) Watch the AI itself: prompt-injection / system-prompt-leak / sensitive-disclosure / cost-abuse · injection-to-action + AI inventory/posture · the eleven conversation-pivoted mesh detectors (OWASP-ASI) Silent without it/ai/* telemetry
actions conduit/actions (V03) The ONE gate every world-changing action passes NoopActuator
plugin.Runtime seam in core; wazero engine in conduit-perf/wasmrt WASM plugin tools (Rules-pack connectors) RefusingRuntime
fedlearn conduit/fedlearn (V02) Privacy-preserving cross-tenant learning (FedAvg + secure aggregation, poison defense) —

The fallback behavior is already correct: assistant.Answer tries the plugged model and, on any error or empty completion, silently degrades to the extractive summary — Answer.Model then reads "extractive" instead of the model name. A dead Ollama endpoint can never fail an analyst's question. That is the platform's model kill switch: unplug the seam and everything still answers.

2.5.2 The five invariants, restated as integration rules

  1. Grounded + cited — a model phrases and synthesizes retrieved events; it never asserts an un-sourced fact. The RAG prompt is built from lake query results; citations ride the answer.
  2. Propose, never execute — every AI-originated action is an actions.ActionRequest. No adapter, agent, or MCP tool ever calls an actuator directly.
  3. AI-authored content is proven content — detections, baselines and playbooks a model drafts go through Compiler/ADF validation (fixtures, canary, rollback) like any human's.
  4. No general model on the hot path — Tier 0 (the Sensor, inline, µs) runs only compiled + validated content. Models live in Tier 2/3, off the hot path, on the brain/GPU pool — model serving is outside the < 1 % CPU / < 150 MB per-host footprint SLO because it never runs per-host.
  5. RBAC + tenancy are server-side, always — /v1/ask is tenant-forced; an MCP client or a copilot UI is never the security boundary.

Autonomy ceiling. Every use case names its rung on the L0–L5 Autonomy Ladder: L0 Observe · L1 Advise · L2 Propose-to-gate · L3 Act-reversible · L4 Act-bounded · L5 Steward. Irreversible ⇒ never above L2. The rung is per-tenant policy, not a model property.

2.5.3 The provider adapter — conduit/llmrt (BUILT 2026-07-11)

Why Ollama first. One API, three deployment shapes: Ollama Cloud (https://ollama.com, API-key auth) for the fast start with zero GPU procurement; a local/on-prem Ollama (http://<host>:11434, no key) for data-resident and air-gapped sites; and any OpenAI-compatible gateway (Ollama exposes /v1/chat/completions, as do vLLM/LiteLLM) — so the adapter written now is the adapter kept when we bring our own model. It is licence-clean (MIT, SPDX-allowlisted) and needs zero new dependencies — JSON over HTTP, net/http + encoding/json, so it lives in the stdlib-only core without touching the D5 two-tier doctrine. And the model stays the customer's: we never bundle weights.

// llmrt.Ollama speaks the Ollama generate API — local daemon or Ollama Cloud.
type Ollama struct {
    BaseURL string        // http://localhost:11434 (local) · https://ollama.com (cloud)
    Model   string        // e.g. "qwen3:32b" · a "-cloud"-tagged model on Ollama Cloud
    APIKey  string        // cloud only; sent as "Authorization: Bearer <key>"
    Timeout time.Duration // default 60s; oracle falls back extractively on expiry
    HTTP    *http.Client  // injectable for tests
}
func (o *Ollama) Name() string { return "ollama/" + o.Model }
func (o *Ollama) Complete(ctx context.Context, prompt string) (string, error) {
    // POST {BaseURL}/api/generate {"model":Model,"prompt":prompt,"stream":false}
    // → {"response": "..."} ; non-200 or empty → error (oracle degrades extractively).
}

CompleteWithUsage additionally reports Ollama's REAL prompt_eval_count/eval_count, so the self-telemetry below carries honest token counts. Wiring is either assembly.New(obs, assembly.WithAssistantLLM(&llmrt.Ollama{…})) or the EVENTLAKE_LLM_* env set (§4.7) — unset ⇒ extractive.

Model selection — the task-class matrix. Selection principle: grounding fidelity beats creativity — the oracle's job for the model is phrase and synthesize retrieved, cited events, not know things. Bias small-and-faithful over large-and-florid; escalate size only where reasoning depth demonstrably pays.

Task class What matters Model class (verify currency at selection time)
Grounded RAG prose (assistant.Answer) Faithfulness to supplied context; ≥ 32k context; low latency Mid-size instruct: Qwen 3 14–32B · Llama 3.3 70B · gpt-oss 20B/120B (MoE) · on Ollama Cloud the large -cloud tags
Case/timeline/report summarization Long context (≥ 128k) for big evidence folds Long-context variants of the above
Detection-engineering assist (Sigma↔native notes, ADF drafts, playbook steps) Code/DSL competence Coder models: Qwen 3 Coder-class · DeepSeek coder-class
Triage/enrichment one-liners at volume Tokens/sec + cost; runs constantly Small instruct: 4–8B class
Embeddings (vector.Embedder) Retrieval quality on security text; dim/size nomic-embed-text · mxbai-embed-large · arctic-embed-class
Adversarial generation (Threat Paths/Validation tiers) Capability + willingness within RoE; run offline The largest available — where approved-org frontier access matters
Guardrail/second-opinion classification Precision, tiny footprint Small classifier-tuned models — optional: llmsec's deterministic catalogs are the primary control, a model is only ever a second opinion

Weights licensing (standing rule). The SPDX code allowlist governs code we ship or link. Model weights are runtime data the customer pulls and loads; we never bundle them into the product, so a custom-licensed weight (Llama Community License, Gemma Terms) never enters our release artifacts. Prefer Apache-2.0/MIT weights when recommending defaults, and record the chosen model + licence per deployment in the aispm inventory. A weight we would redistribute (a EventLake-tuned model) must itself be clean to distribute — that is a §2.5.9 gate, not an adapter concern.

2.5.4 Residency, and masking before the model

Ollama Cloud On-prem / local
Data boundary Prompts (with lake-event excerpts) leave the deployment Everything stays inside
Start cost Zero GPU; an API key GPU box or pool
Models Largest open models, managed What fits the hardware
Air-gap No Yes (pull once, transfer into the enclave registry)
Fit Dev/dogfood, our own SaaS brain, tenants who accept the DPA Regulated/DPDP-resident/defense, and the end-state default

Rules binding either shape: provider choice is per-deployment policy, never hard-coded; a tenant-visible answer must record which model produced it (Answer.Model does); and masking before egress is REQUIRED for any remote provider.

Masking (BUILT). Prompts are built from lake events, and lake events carry PII and secrets. Before a prompt crosses the deployment boundary the excerpts pass the classify masking pass — the same masking doctrine the findings/forensics reads use. It is ON by default whenever BaseURL is not loopback/RFC-1918/link-local, and a DNS name fails CLOSED as remote — locality is unprovable without trusting a resolver. Redaction is at the Confidential export floor (classify.Policy.ForExport's own floor). AllowUnmaskedEgress (EVENTLAKE_LLM_UNMASKED_EGRESS=1) is the explicit opt-out; ForceMaskEgress overrides everything toward masking. Local/on-prem models may run unmasked under the deployment's own policy.

2.5.5 Self-monitoring from day one — the dogfood clause

The moment the adapter shipped, our own copilot became an LLM gateway — so it emits the same telemetry we sell detection over: each Complete call is logged as an it/ai/llm-request event (model, principal, masked prompt/completion digests, token/latency counts) through the existing genai path. For free, on ourselves: llmsec watches OUR copilot for prompt-injection attempts, system-prompt-leak echoes, sensitive disclosure and cost-abuse hammering; aispm lists the connected model/provider as an AI asset with posture (ungoverned if we ever run an agent identity absent from the itdr NHI inventory); agentsec watches our own mesh. The platform that detects AI-security incidents must never run its own AI unwatched.

2.5.6 Embeddings — the second endpoint (BUILT 2026-07-11, P1)

llmrt.OllamaEmbedder (POST /api/embed) upgrades C11 incident recall from lexical-hash similarity to semantic similarity — "OAuth consent-grant abuse" then recalls "illicit app authorization in O365" — and powers the platform knowledge RAG index (assembly/knowledge.go: the detecteng catalog + every framework matrix's techniques → KnowledgeSearch + cited assistant.Answer.Knowledge context, extractive path included). The rules, all enforced in code:

2.5.7 The use-case catalog — what connectivity unlocks

The operational cut, grouped by surface, each row naming the seam it rides, its autonomy ceiling and its status. Day-1 = works with the P0 adapter alone; +build = needs the named small build.

# Use case Rides Rung Status
Analyst copilot (SOC L1–L4 assist)
1 NL→query — "critical alerts on db-1 today" compiled to a governed store.Query, query shown for trust assistant.Plan · /v1/ask L0 BUILT (no model needed)
2 Fluent grounded answers — RAG prose over retrieved events, citations preserved assistant.Answer + LLM seam L0 Day-1
3 Alert triage brief — what happened / why it matters / what to check next, from the alert's event.Reason chain ask over alert streams L0 Day-1
4 "Explain this detection" — the detecteng entry + Reason chain in analyst prose (why it fired, what would make it an FP) detecteng + framework RAG L0 Day-1
5 Case narrative / IR timeline draft forensics · worldline L1 Day-1
6 Recommended containment — proposed with evidence, submitted as an ActionRequest the gate Holds assistant.Suggest → conductor L2 BUILT path; prose Day-1
7 Shift handover / morning digest — last 12 h of alerts/cases/posture deltas scheduled ask + report L0 +build (a report template)
8 ATT&CK/ATLAS context — "what technique is this, who uses it, what's our coverage" framework catalogs RAG L0 Day-1
9 Copilot audit trail — every Q&A + proposal emitted as it/assist/answer / it/assist/action-proposed lake — BUILT (spine.emitAssistTrail, extractive path included; proposals causally chained to their answer)
Detection engineering (the Compiler/ADF loop)
10 Draft a detection from described behavior → ADF content, gated by behavioral fixtures before it exists ADF + Compiler L5 path BUILT (A5)
11 Sigma→native translation review — what the converted rule does and where semantics drifted sigma pipeline L1 Day-1
12 Tuning proposals from FP clusters — "these 40 dismissals share a service account; propose the exclusion" cases dispositions + detect L2 +build
13 Coverage-gap narrative — the gap map as prose framework L0 Day-1
14 Auto-document rules — detecteng entries → runbook paragraphs detecteng L0 Day-1
Hunting & investigation
15 Hypothesis generation — an intel item → concrete hunt queries over OUR estate scout + Plan L1 Day-1
16 Timeline narration — the causal graph told as an attack story, every step citing its event worldline L0 Day-1
17 "Have we seen this before?" — vector recall hits summarized against the current incident vector (C11) L0 Day-1 (better with §2.5.6)
18 Hunt-pack drafting — osquery/YARA seeds from a hypothesis, human-reviewed hunting packs L1 Day-1
Reporting, compliance & governance (Reports)
19 Report prose — the numbers stay computed; the model only writes connective prose report L0 +build (template hook)
20 Executive/board summary at CISO altitude report + ask L0 Day-1
21 Compliance narrative — comply-pack evidence → auditor-facing control write-ups (multi-regime) compliance-as-code L1 Day-1
22 Maturity roadmap narrative maturity L0 Day-1
Exposure & posture (EASM · CTEM · vuln · Firewall)
23 Fix-now justification — CTEM apex items argued in writing ctem L0 Day-1
24 Exposure explanation in owner-facing language easm L0 Day-1
25 Intent authoring assist — NL → Firewall intent draft, compiled/attested deterministically, distributed only through the conductor bulwark L2 +build
26 Hardening rationale — ATT&CK/D3FEND mapping for a proposed baseline change asset-hardening L1 Day-1
Identity, fraud & data security
27 UEBA anomaly explanation — XAI over the classical tier; the model explains, never replaces, the ms-latency math ueba L0 Day-1
28 Fraud case narrative for the disputes team fraud L0 Day-1
29 PII adjudication assist — ambiguous hits proposed-with-rationale; the validator verdict stays deterministic classify L1 +build
30 DLP incident summary — what nearly left, through what channel, consent posture classify + consent L0 Day-1
ITOps & observability
31 RCA draft — cross-pillar correlation told as a root-cause chain observability arms L1 Day-1
32 Alert-storm digest — 400 infra alerts folded to three underlying causes apm/infra L0 Day-1
33 Capacity/SLO breach explanation + sizing pointer sizing model L0 Day-1
Comms, PSIRT & operations (Messages · Certificates · Trust Center)
34 Approval context — plain-language render of a held conductor action inside the Messages approval comms + conductor L0 +build (approval fold)
35 Cert-expiry impact brief in change-window language notary + asset L0 Day-1
36 Advisory drafting — PSIRT/VDP prose from the tracked vuln record (CVSS stays computed) trust-center L1 Day-1
Adversarial tiers (Threat Paths · Validation · deception)
37 Future-threat hypotheses — the Threat Paths (formerly Mythos) generative red-team imagining attacks against the twin, offline (Tier 3) Threat Paths L1 +build; model class matters
38 Validation plan drafting — Validation RoE-scoped probe plans, executed only through the gate crucible L2 BUILT 2026-07-11 (A6)
39 Deception content — believable honeytokens/decoy docs/canary records at scale deception L1 Day-1

2.5.8 The governed agent runtime — conduit/agentrt (BUILT 2026-07-11)

Per-argument provenance (2026-10-16b). Taint used to cover the whole run. Now each argument of a proposed action is matched against the tainted observations the planner saw (whole value, or normalised tokens of four or more characters; tokens from the operator's own goal do not count). A match on a critical argument (Tool.CriticalArgs, default target · ip · host · recipient · url · command) holds the step for a human even when the autonomy gate would allow it, and the event carries tainted_args naming the argument and the observation it came from (agentrt/provenance.go). This is the deterministic reference-monitor half of the CaMeL-style design; a second, quarantined interpreter was declined as duplicating the existing taint gate.

What "agentic" adds, precisely. One-shot GenAI is ask → retrieve → phrase. Agentic AI is a loop: plan → call a tool → observe → re-plan, until a goal or a budget is hit. EventLake already runs deterministic loops (the triage orchestrator; the C14 playbook engine with its Compensates undo door). The agentic upgrade is letting a model choose the next step inside those loops — which is exactly the moment the safety substrate must already be underneath, because a model-planned step is a hypothesis, not a fact.

The design, as built:

The A1–A10 roster. All ten ship. Each names its ceiling, its gate, and the proofs that hold it.

# Agent The loop Rung
A1 ✅ Autonomous triage — model-planned branches inside the triage orchestrator alert → iterative context → verdict + proposal L2; L3 for reversible containments after a measured precision record
A2 ✅ Multi-step investigation — "investigate this identity", iterative lake queries, cited investigation doc + worldline ask-loop over lake reads L0–L1 (read-only)
A3 ✅ Case-work agent — gather evidence into a forensics.Bundle, keep the timeline current case → queries → bundle L1
A4 ✅ Playbook synthesis — draft a C14 playbook for a recurring incident shape dispositions → draft → fixture-test L2
A5 ✅ Coverage steward — draft ADF detections for uncovered techniques, walk them through Compiler validation gap → author → prove → canary L5 — the full closed loop, the end state
A6 ✅ Validation operator — plan and run RoE-scoped validation campaigns, feed misses back to the steward hypothesize → gated probe → measure L2/L3 within RoE; Tier 3 offline
A7 ✅ Connector onboarding — infer a decoder field mapping + default rules from samples of an unknown source samples → mapping → fixtures L1/L2
A8 ✅ Posture remediator — CTEM fix-now item → remediation ActionRequest with evidence, canary-first apex item → proposal L2; L3 for reversible classes is the conductor's call
A9 ✅ Report agent — "build me the monthly fraud report as a dashboard" → report definition + saved query NL → report spec L1
A10 ✅ Assistant deep-answer — decompose a hard question into sub-queries, reconcile, answer with the union of citations plan → N queries → synthesize L0

The four authored-content agents share one shape — author → prove → propose → a human releases → the actuator re-runs the entire gate — and it is worth stating once because it is the platform's answer to "can an AI change what the platform detects?":

Three more are worth their own note because each closes a loop:

The REST run-door (BUILT 2026-07-12). The roster is operator-launchable over /v1/agents/*: the listing is self-describing (launch id · NHI ref · rung · what the one input means · whether the agent ACTS); a launch has identity FORCED from the authenticated principal (a body field can never widen scope) and RBAC split on the roster's own declaration — the two structurally read-only agents launch at read clearance like /v1/ask, every proposal-filing or record-writing agent requires act; and GET /v1/agents/runs is a FOLD of the it/ai/agent-lifecycle telemetry through the same masked + audited path — no private run store; the lake already is one.

The safety substrate is not optional equipment. Everything above sits on the conductor's guardrail chain (crown-jewel HOLD, blast-radius caps, ReversibilityGuard), canary→verify→auto-rollback, per-tenant autonomy policy, the global kill switch, and the agentsec/aispm/llmsec arms reading our own telemetry. An agent that cannot be watched, budgeted, and stopped is not built.

2.5.9 MCP — three postures, all three built

MCP is the open, vendor-neutral protocol (JSON-RPC 2.0; stdio and streamable-HTTP transports; spec + SDKs under MIT — clean-room-friendly, and small enough to implement stdlib-only, the gRPC precedent) through which a model host discovers and calls tools, reads resources, and uses prompts.

Posture A — EventLake as an MCP server (BUILT 2026-07-11). A stdlib clean-room server inside conduit/api at POST /v1/mcp: same listener, same auth (Bearer key or session → Principal), RBAC + tenant forced server-side. Stateless streamable-HTTP, single JSON-RPC messages, version negotiation across 2024-11-05/2025-03-26/2025-06-18, role-FILTERED tools/list discovery, per-tool RBAC inside the dispatcher, and the detecteng + docs corpora as traversal-proof resources. Any MCP host — Claude Desktop/Code, an IDE, a customer's own agent platform — becomes a EventLake operator surface with zero new UI. Two non-negotiables: no MCP tool actuates (platform_propose_action is the only world-changing verb and it terminates at the gate), and every MCP call is telemetry (logged as it/ai/tool-call with the caller principal, so the customer's aispm inventory shows exactly which external agents operate their SOC). The tool table is §4.6.

Posture B — EventLake as an MCP client/host (BUILT 2026-07-11). agentrt/mcp.go + manifest.go: a clean-room stdlib streamable-HTTP JSON-RPC client (the server's counterpart — initialize/version negotiation · notifications/initialized · tools/list · tools/call), registered per deployment via assembly.WithMCPConnector(manifest). The governance mapping is mechanical and enforced in code:

What to connect first — each lands as a Rules-pack connector manifest (server endpoint + declared tools → class mapping + default allowlist + the attestation pin, one-click):

Category Examples of MCP-exposed systems Class
ITSM/ticketing ServiceNow, Jira read = enrichment · write = gated action
Threat intel MISP, OTX, VirusTotal-class lookups enrichment
EDR/XDR consoles vendor MCP servers as they appear read = enrichment · contain = gated action
Cloud control planes AWS/Azure/GCP inventory + config reads enrichment (writes: gated, rare)
Identity providers Okta/Entra directory + session reads enrichment · disable/revoke = gated
Code/CI GitHub/GitLab for detection-as-code PRs gated content proposals
Comms Slack/Teams notification bridges gated (send = outward-facing action)
Sandboxes/scanners detonation services, vuln scanners enrichment / gated scan-start

Posture C — securing MCP estates (largely BUILT). MCP traffic is agent-mesh traffic. An MCP gateway's logs land through decode/genai as it/ai/*, and the shipped arms cover the MCP threat classes directly:

MCP-specific threat Detected as (shipped)
Tool-description poisoning / instructions smuggled in tool output agentsec mesh-injection (ASI-T12) · llmsec prompt-injection
Poisoned output fanning through a multi-server pipeline taint-cascade (T05)
Server/agent impersonation (claimed ≠ attested identity) impersonation (T09)
A tool call outside the client's declared toolset scope-violation (T03)
Injection that reaches an acting tool aispm injection-to-action (Critical)
Approval flooding of the human in the loop hitl-fatigue (T10)
Exec-payload idioms in tool arguments code-attack (T11)
Zombie client acting after deregistration rogue-agent (T13)

Remaining follow-on: surface a catalog-attestation refusal as an aispm posture marker + a page. Today it fails the dial and drops the connector's tools — fail-closed, but not yet a first-class posture signal.

Beyond MCP, the adjacent connectivity reuses the same seams: OpenAI-compatible gateways (vLLM, LiteLLM, llama.cpp server) take the same llmrt adapter shape; A2A-protocol / framework meshes (LangGraph, AutoGen, CrewAI) are already tolerated as telemetry producers by decode/genai but as runtimes stay outside the trust boundary and talk to EventLake only via Posture A; WASM plugin tools (§2.4) are sandboxed in-process tools where an external server is overkill; and fedlearn is cross-tenant model improvement without moving data.

2.5.10 Bringing — or building — our own model

Naming disambiguation, recorded because the collision is real. EventLake "Threat Paths" (formerly "Mythos") is our generative future-threat subsystem. Claude Mythos 5 is Anthropic's model — the same underlying model as Claude Fable 5, offered without Fable's dual-use safety measures to approved organizations only. The collision is coincidence; this volume always says "Claude Mythos 5" for the vendor model.

Route 1 — frontier model via API (fastest to maximum capability). Consume a frontier model through the vendor API, AWS Bedrock or GCP Vertex, behind the same assistant.LLM seam (llmrt.Anthropic, another ~80-line stdlib adapter; the messages API is JSON over HTTP). Why: the reasoning ceiling — Tier-2 agentic planning and Tier-3 adversarial invention benefit most, and grounding + gating are OUR layers and don't move. Residency: Bedrock/Vertex pin the region; PrivateLink/VPC endpoints keep traffic off the public internet — but masking still applies, because residency is not anonymity. What you never get: the weights. No frontier vendor ships them; "self-hosted Claude" does not exist. On-prem- only sites use Routes 2/3.

Route 2 — partially our own: a tuned open-weight model (the recommended main line). Fine-tune an open-weight base into a EventLake security model, served wherever the customer's data lives. "Partially on top of a frontier model" is legitimate in exactly three shapes, and only these: (1) frontier as orchestrator, ours as workhorse — the frontier model plans/judges, the tuned open model does bulk inference; no licensing question at all, just two adapters behind one seam; (2) sanctioned customization inside a licensed service — where the cloud provider offers it, the teacher-student relationship stays inside that service's terms (verify current capability + terms at build time; these offerings move); (3) frontier-assisted data curation — the frontier model helps label/clean/structure OUR corpus, while the training run targets an open-weight base.

The hard line (recorded): using a frontier model's outputs to train a competing model outside a sanctioned mechanism violates provider usage terms. Route any distillation ambition through shape 2 or a written agreement. We do not launder capability.

The data flywheel — what makes our model ours. The moat is the substrate; the flywheel is the substrate feeding the model. Corpus, all consent-gated and classify-masked before any training set is cut (never train on raw PII; per-tenant opt-in; fedlearn where data cannot move): alert evidence → analyst-approved narratives · case dispositions (true/false-positive labels) · Reason chains · the framework catalogs · detecteng entries · the docs corpus · the llmsec/agentsec marker catalogs as adversarial negatives. Method: continued pretraining where budget allows, else SFT (LoRA/QLoRA on 7–70B) + preference tuning (DPO) on analyst accept/reject signals. Eval harness BEFORE the first training run — golden sets from detecteng fixtures + spine E2E scenarios, scored on grounding fidelity, query-compile accuracy, injection resistance, refusal correctness, and latency/cost. A model version is Compiler-governed content: validate → canary (shadow a % of asks, compare) → promote/rollback, signed and versioned in the registry.

BUILT (P4, 2026-07-11): conduit/modelpilot is this made real — the corpus cut (masked + per-tenant consent-gated, the llmsec catalogs as refusal negatives, sha256-bound, never landing the pairs on the lake), the eval harness (the four gates, injection zero-tolerance), and the Compiler-governed registry + shadow-canary (validate → canary vs incumbent → promote/rollback, the artifact digest binding base+corpus+eval+weights, D22-durable). What stays OUT of repo is only the GPU-bound training run and the served endpoint; everything that GOVERNS the model's data, quality and promotion is in-repo and tested.

Route 3 — fully our own (honest assessment). Pretraining a frontier-class model is out of reach and out of mission — the doctrine is explicit that the substrate, not the model, is the moat. What is feasible at the right stage: a 7–14B security-domain model via continued pretraining + SFT on the Route-2 corpus (single-digit-node GPU budget, weeks not years), giving a fully-redistributable, fully-air-gappable model with no upstream licence entanglement (train from an Apache-2.0 base). Decision gate: revisit when the flywheel has ≥ 6 months of curated pairs and the tuned-open-weight model's eval ceiling — not before.

Hosting — the same seam everywhere. On-prem/air-gapped: Ollama (MIT) for the laptop-to-rack continuum, vLLM (Apache-2.0) when throughput/batching matters (both speak OpenAI-compat — one adapter). Sizing rules of thumb (verify per model/quant): 7–14B Q4–Q8 → one 24 GB GPU · 32B → 48 GB (or 2×24) · 70B Q4 → ~2×48 GB · full-precision 70B+ → a multi-GPU node. Embeddings are CPU-tolerable at small scale. The GPU pool sits with the brain, never on Sensor hosts (invariant 4). Air-gap path: pull and convert outside, transfer via the enclave's media process into a local registry, serve with no egress — the hash embedder + extractive defaults mean even a model-less enclave stays functional. On AWS: EC2 GPU instances running vLLM/Ollama, SageMaker endpoints where its ops model fits, Bedrock for managed models AND Custom Model Import to serve our own fine-tune behind a managed API, with region pinning + PrivateLink for residency. Other clouds are additive per the global-quality rule (Azure AI Foundry, GCP Vertex, OCI; Indian sovereign GPU clouds for strict DPDP residency). In every case the platform sees one thing: an assistant.LLM/Embedder endpoint URL in EVENTLAKE_LLM_* config.

Securing our own model (the dogfood clause, again). The moment we host a model we are an AI estate: llmsec sits in front of it, aispm inventories it, Validation red-teams it on a schedule with the llmsec marker catalogs + ATLAS TTPs, its artifacts are signed and SBOM'd — and the kill switch is architectural: unplug the seam, the platform answers extractively, nothing breaks.

2.6 Protocol & OEM integration strategy

How EventLake supports the long tail of protocols and OEM products customers actually run — closed, semi-open and proprietary — without violating the two hard constraints (clean-room implementation; permissive licences only).

The thesis in one line: the clean-room and permissive-licence constraints bind the core, not the platform. A sandboxed plugin runtime is what lets the platform decode anything — proprietary, GPL, customer-private, encrypted — while conduit stays stdlib-only and permissive. S7comm is not a protocol EventLake is forbidden to support; it is a protocol that belongs in the plugin/partner tier rather than the clean-room core, and once the runtime exists it has a home instead of a refusal.

2.6.1 The reframe — "closed source" is five problems, not one

The hardest, rarest case (a genuinely-closed protocol with no obtainable spec) has anchored too much thinking. The real distribution of what a customer estate contains, and the different legal answer each category carries:

# Category Examples Spec obtainable? The real work Clean-room risk
1 Open spec, proprietary protocol EtherNet/IP·CIP (ODVA), PROFINET (PI), OPC-UA (OPC Foundation), Modbus, BACnet, DNP3, FINS, IEC 61850/MMS, IEC 60870-5-104 Yes — purchasable / freely licensable from the consortium Buy/obtain the spec, clean-room implement Low — identical to what ships today
2 Documented vendor products Palo Alto, Cisco, Fortinet, CrowdStrike, SentinelOne, Microsoft Defender, Okta, AWS/Azure/GCP, Splunk HEC, ServiceNow, SAP, Zscaler Yes — public API / log-format docs (CEF, LEEF, JSON, syslog) API client / webhook receiver / format parser. No wire dissection at all. None — documented integration engineering
3 Semi-open / community-documented Some MELSEC variants, GE-SRTP, certain vendor BACnet/DNP3 extensions, proprietary syslog dialects Partial — community reverse-engineering + own capture analysis Clean-room from community docs + captured-traffic analysis; provenance discipline Medium — provenance matters
4 Genuinely closed, no obtainable spec S7comm (classic Siemens), some proprietary security/crypto formats, undocumented OEM control planes No Reverse-engineering, OEM partnership, or sandboxed third-party/customer plugin High — a per-protocol legal decision
5 Opaque / encrypted on the wire DTLS-wrapped CoAP, proprietary TLS, encrypted C2, vendor-encrypted ICS Spec doesn't help without keys Upstream termination, key material, or metadata-only analytics N/A — a different problem

The consequence that drives the whole strategy: the overwhelming majority of "support all the OEMs" demand is rows 1 and 2 — obtainable specs and documented APIs, which carry no clean-room conflict whatsoever. The genuinely-hard, must-make-a-legal-call tier (row 4) is a small set. So the strategy is mostly "build the machine that does rows 1–2 at scale," and "govern row 4 as a logged exception." Row 5 is handled by metadata-only analytics and upstream decryption, never by pretending to decode ciphertext.

2.6.2 The seams that already exist, and the keystone

Seam (built) What it gives the integration story
connector.Manifest + Registry A decoder + default rules + config schema + exposed methods + in-product docs bundled as ONE enable/disable unit — the packaging contract for every integration
conduit.Decoder The universal raw bytes → canonical events interface every decoder satisfies — built-in or plugin
schemaread Never-drop raw store + read-time parser registry + retro-reparse
event.Original The verbatim source carried inline (ECS event.original)
canonical event + ot/industrial/* shape The normalization target. The ics arm proves ten OT protocols feed one detection model with zero new detector code
actions + Actuator seam The conductor-gated egress for the write side of integration
kms Signs plugin manifests — the trust root for a marketplace
Rules (formerly Recipes) The distribution + signed-feed-sync home for third-party/customer connectors

The keystone was the WASM/wazero plugin runtime, and it does two load-bearing things at once:

  1. Breadth without core bloat or de-purification. A decoder shipped as a WASM plugin never enters the stdlib-only conduit module. The core stays at its package count, stdlib-only and SPDX-clean, regardless of how many hundred protocols the platform decodes.
  2. It is the structural answer to the licensing problem. A third-party or customer-private decoder running in a sandbox is not linked into EventLake — so its licence is its own concern, not a contamination of the permissive core. A GPL or proprietary decoder can run as a plugin where it could never be a built-in. That single property is the difference between "the protocols we had time to clean-room" and "any protocol anyone is willing to write a decoder for."

Never-drop passthrough — an unsupported protocol is visible, never blind (BUILT 2026-06-12). schemaread's never-drop store is now the default posture for unknown sources, not a fallback: assembly.Passthrough{,ForTenant} is the door (raw → store.AppendRaw → a visible un-parsed event carrying size, timing and the tap's src_ip/dst_ip peer endpoints); assembly.IngestRaw through an unknown/disabled connector or a failing decoder falls through to the passthrough instead of dropping (the error stays loud, the frame is kept); the first frame from a new undecoded source announces a one-time it/platform/unknown-source discovery event; schemaread.Store keeps per-(source, format) counters that survive the raw RAM quota's eviction, so the demand history outlives the bytes; assembly.UndecodedSources() / GET /v1/sources/undecoded serve the backlog ranked by unparsed volume; and assembly.ReparseRaw() runs the retro-reparse half. The demand-signal loop this creates is a durable advantage a platform without a never-drop substrate cannot run.

2.6.4 Protocols vs products — most "OEM coverage" is connectors, not decoders

When a customer says "we run Palo Alto, CrowdStrike, Okta and Azure," they do not want wire dissection — they want their products' telemetry ingested and their products' control planes actuated. Both halves are first-class:

2.6.5 The operating model and the phased build order

EventLake cannot build every decoder for every OEM. The goal is the machine that lets the ecosystem build them, with EventLake owning the open-spec core and the trust spine: a connector/decoder SDK (BUILT — connector/sdk, §2.4.3); Rules as a real marketplace with a trust spine; demand-driven prioritization from the raw store; and partner programs that flip the maintenance burden to the party that holds the spec — proprietary protocols change without notice, and OEMs generally want their gear observable.

The phased order, with what actually landed:

# Phase Status
1 Build the WASM plugin runtime behind the connector seam — the highest-leverage single piece; sandbox + KMS signing + capability limits + resource budget + fuzz gate BUILT. Core half conduit/plugin (stdlib-only): the KMS-signed Descriptor digest-bound to the exact module bytes, the capability + budget contracts, the trust tiers, the Host policy, and the Runtime seam with a RefusingRuntime default that fails closed when no engine is linked. The wazero engine landed in conduit-perf/wasmrt — the repo's first external dependency and first perf-tier module, signed off under D5 as a capability/licence-isolation argument, not a D4 perf number
2 Promote schemaread passthrough to the default for unknown sources BUILT 2026-06-12 (§2.6.2)
3 Stand up the connector SDK + OCSF/ECS-aligned normalization, convert IT/product breadth into catalog connectors BUILT 2026-06-12/13 — the SDK conformance harness; the bidirectional OCSF interchange layer (FromEvent exports, ToEvent + a conduit.Decoder ingest OCSF-native telemetry, unknown classes landing as Base-Event fallbacks that preserve every field); the CEF and LEEF catalog connectors; and the cloud/identity matrix — decode/cloudtrail, decode/oktasystemlog, decode/entra, decode/gcpaudit, decode/azureactivity. All five bundle detect.UebaRule, so enabling any + AnalyzeUEBA gives cloud-identity impossible-travel / password-spray with zero new detector code
4 Clean-room the open-spec OT protocols natively COMPLETE 2026-06-13 — ten protocols, one detection model, zero copied code (§2.7 Part A.1). The last four landed as IEC 60870-5-104, EtherNet/IP·CIP, OPC UA, PROFINET (the first Layer-2 decoder) and IEC 61850/MMS (the deepest stack — TPKT → COTP → session → presentation → ASN.1 BER, all clean-room)
5 Institute the per-protocol intake/decision record for the genuinely-closed tier INSTITUTED 2026-06-13 — the living register is §2.7
6 Bring actuation connectors to parity so OEM integration is bidirectional and conductor-gated ongoing — the seam and the gate exist; breadth is the work
7 Open Rules as a certified marketplace the runtime install surface shipped 2026-07-19; certification pipeline is the remainder

What this does not change. The hard constraints are untouched — they bind the conduit core, which stays stdlib-only and SPDX-clean. The plugin runtime, the spec-licensed native decoders, the OCSF normalization and the marketplace all sit around that core, never inside it. The architecture rules are preserved: a plugin decoder emits canonical events onto the one lake exactly like a built-in, an actuation plugin passes the conductor exactly like a built-in actuator, and detection is written once against the canonical shape.

2.7 The protocol decision register

The living, auditable register of how EventLake decided to support (or defer, or refuse) each protocol and interchange format it has touched — the operational instrument of §2.6.5 step 5.

Why it exists. A clean-room platform that promises "support all our OEMs" must be able to answer, for any protocol on the table, exactly how it was handled and why that choice is legally and architecturally defensible — and must be able to revisit it as demand, spec availability or partnership status changes. Scattered "S7comm refused on clean-room grounds" notes across a codebase do not constitute a process. This register does: every decision is a logged record with a chosen path and a revisit trigger, and adding a new protocol is a documented intake, not an ad-hoc call.

This is governance metadata, not runtime code. A decision record decides whether and how a decoder gets built; it never enters the core.

The chosen-path vocabulary is fixed: native clean-room · documented integration · sandboxed plugin · passthrough-only · OEM partner · legal-reviewed clean-room. A record's status is BUILT · DEFERRED (recognized, path chosen, not yet built — carries a revisit trigger) · GOVERNED (a row-4 decision logged, with a path that is intentionally not a native core decoder today).

2.7.1 Part A — the GREEN register (BUILT)

The audit trail proving the clean-room provenance of every decoder and interchange parser that ships. All twenty out-of-box connectors are rows 1–2, so each carries no clean-room conflict whatsoever. No reference dissector source was opened while writing any of these.

A.1 — Open-spec OT protocols (Category 1, native clean-room). Ten protocols, one protocol-agnostic ics detection model (unauthorized-write / function-scan / exception-storm), zero copied code:

Protocol Package Spec / source Transport Decision
Modbus/TCP decode/modbus Modbus Org public spec TCP 502 BUILT · native clean-room
DNP3 / IEEE-1815 decode/dnp3 IEEE 1815 (CRC vector verified) TCP 20000 BUILT · native clean-room
BACnet/IP decode/bacnet ASHRAE 135 (Annex J BVLC) UDP 47808 BUILT · native clean-room
CoAP decode/coap RFC 7252 UDP 5683 BUILT · native clean-room
MQTT 3.1.1 decode/mqtt OASIS MQTT 3.1.1 TCP 1883 BUILT · native clean-room
IEC 60870-5-104 decode/iec104 IEC 60870-5-104 TCP 2404 BUILT · native clean-room
EtherNet/IP + CIP decode/enip ODVA EtherNet/IP + CIP (Volume 1/2) TCP 44818 BUILT · native clean-room
OPC UA decode/opcua IEC 62541 (opc.tcp binary) TCP 4840 BUILT · native clean-room
PROFINET (PN-DCP) decode/profinet IEC 61158/61784 EtherType 0x8892 (L2) BUILT · native clean-room
IEC 61850 / MMS decode/iec61850 ISO 9506 (MMS) / IEC 61850-8-1 TCP 102 (TPKT/COTP/MMS) BUILT · native clean-room

The IEC 61850 stack pays a dividend below: its clean-room TPKT (RFC 1006) → COTP (ISO 8073) → session (ISO 8327) → presentation (ISO 8823) reader is reused by the S7comm record, whose transport is the same TPKT/COTP envelope. The genuinely-closed part of S7comm is only the PDU above COTP.

A design note the whole family shares: the write flag, the exception flag and function_code are scoped so that routine operation never trips a detector. A master's interrogation or clock-sync polling carries no function_code while a command-space sweep does; OPC UA and MMS are service-rich, so only a mutation-surface sweep trips the function-scan; ENIP's ListIdentity (the internet-scanner fingerprint) is Medium discovery while OPC UA's GetEndpoints — part of every legitimate connect — is Info. Element and tag payloads are never decoded.

A.2 — Documented interchange formats & standards (Category 2, documented integration).

Format / source Package / connector Spec / source Decision
Syslog decode/syslog RFC 3164 / RFC 5424 BUILT · documented integration
OpenTelemetry (OTLP) decode/otlp OTLP (OpenTelemetry, Apache-2.0) BUILT · documented integration
osquery decode/osquery osquery TLS-logger result schema (Apache-2.0 product) BUILT · documented integration
SNMP traps (v1/v2c) decode/snmp RFC 1157 / RFC 3416 BUILT · documented integration
NetFlow / IPFIX / sFlow decode/netflow RFC 3954 (v9) · RFC 7011 (IPFIX) · sFlow.org v5 BUILT · documented integration
pcap / pcapng + DPI decode/pcap libpcap / pcapng format; DNS RFC 1035, TLS RFC 8446/6066, HTTP/1.x BUILT · documented integration
CEF (ArcSight) decode/cef ArcSight Common Event Format (public spec) BUILT · documented integration
LEEF (QRadar) decode/leef IBM LEEF 1.0 / 2.0 (public spec) BUILT · documented integration
OCSF ocsf OCSF schema (Apache-2.0) — bidirectional interchange BUILT · documented integration

OCSF is the normalization target, not just a connector: EventLake both ingests OCSF-native telemetry and exports canonical events in OCSF shape, so the field-mappings the ecosystem already writes are reusable both ways.

A.3 — First-party / reference products. decode/mfa — the EventLake reference product's auth_events → siem_outbox stream. BUILT · first-party.

2.7.2 Part B — the DEFERRED register (recognized, path chosen, not yet built)

Each carries a chosen path and an explicit revisit trigger. These are not refusals; they are scheduled work with a recorded rationale.

Protocol Cat Chosen path Why deferred Revisit trigger
GOOSE / IEC 61850-9-2 Sampled-Values 1 native clean-room A separate L2 protocol family from the TCP-102 MMS station-bus decode/iec61850 covers — a distinct decoder, not an extension. Open spec, so zero clean-room risk when built (the PROFINET L2 path is the template) Substation-protection / sampled-values demand in the raw-store signal, or an engagement requiring process-bus visibility
MQTT 5.0 property decode 1 native clean-room decode/mqtt refuses a v5 CONNECT loudly today; the 3.1.1 wire shape dominates IIoT brokers. Open spec — additive when built v5 broker traffic in the raw-store signal, or a customer on a v5-only broker
Modbus RTU / serial framing 1 native clean-room decode/modbus covers Modbus/TCP (the dominant transport); RTU adds serial CRC framing over the same PDU Serial-tap captures in the demand signal
EtherNet/IP implicit I/O (UDP 2222) · OPC UA over HTTPS/WebSockets · IEC-104 sequence-flow tracking · multi-segment MMS reassembly 1 native clean-room (additive) Recorded per-decoder honest deferrals — the control plane (the security surface) is built; these are the cyclic-data / alternate-transport / stateful-session refinements A detection use-case needing the cyclic data plane or a stateful session view
DTLS-wrapped CoAP 5 metadata-only / upstream termination Encrypted transport — the spec doesn't help without keys. decode/coap decodes plaintext CoAP; DTLS is the tap's or terminator's concern A deployment that terminates DTLS upstream and feeds EventLake the plaintext, or a metadata-only analytics requirement

2.7.3 Part C — the genuinely-closed tier (Category 4), GOVERNED

A Category-4 protocol has no obtainable spec and high clean-room risk (its only references are typically copyleft community dissectors). The decision is not a blanket no — it is a logged record with a chosen path that is intentionally not a native core decoder today, and a revisit trigger.

C.1 — S7comm / S7comm-plus (Siemens S7 PLC protocol) — the canonical worked example. The six intake fields, filled:

Field Record
Protocol / vendor S7comm (classic, S7-300/400) and S7comm-plus (TIA Portal v13+, S7-1200/1500). Vendor: Siemens. Transport: ISO-on-TCP, TCP 102. Segment: OT (factory & process automation)
Spec availability None. Siemens publishes no protocol specification. Knowledge exists only via community reverse-engineering. S7comm-plus is integrity-protected and session-based; even community documentation of it is partial
Reference-impl licence The canonical references are copyleft: Wireshark s7comm = GPL-2.0; Snap7 = LGPL-3 + commercial dual-licence. Both are forbidden to open while writing a clean-room decoder — precisely the GPL-contamination trap §2.6.3 names
Jurisdictional risk Reverse-engineering an undocumented protocol carries anti-circumvention (DMCA §1201 / EU equivalents) exposure only when a technical protection measure is bypassed. S7comm-classic is plaintext with no TPM → low exposure on that axis. S7comm-plus carries integrity protection → higher exposure; bypassing it is a counsel question, not an engineering one. Counsel input required before any clean-room implementation
Demand Expected high (any Siemens-heavy OT estate will surface S7 traffic in the raw-store signal). To be confirmed per-engagement from the actual backlog rather than assumed
Chosen path Tiered (below). Not "no forever" — a staged path from zero-risk-now to legal-reviewed-clean-room-later, with S7comm-plus held to plugin/partner only

The tiered path. The key technical finding that makes this concrete rather than a refusal: S7comm rides the same transport stack EventLake already clean-rooms — TPKT (RFC 1006) → COTP (ISO 8073) → the S7 PDU, and decode/iec61850 already ships a clean-room bounds-checked TPKT/COTP reader. Those transport layers are open ISO/IETF specs with zero clean-room risk. So:

  1. NOW — passthrough + clean-room COTP envelope (zero risk, real value). The never-drop substrate already captures S7 frames: volume, cadence and peer-set are alertable today. On top of that, the connection envelope is decodable clean-room from the open transport specs — TCP-102 sessions, COTP connection setup (TSAPs), and the S7 PDU treated as an opaque payload with size and direction. A new station opening an S7 connection, a connection from an unexpected peer, or an anomalous PDU-volume burst are all detectable without ever touching the closed PDU layer. BUILT + WIRED + TESTED (2026-10-24wb). decode/iec61850 already decodes the TPKT/COTP envelope clean-room and emits the opaque S7 PDU as an ot/industrial/iec61850 cotp-data event carrying cotp_payload_first_byte (0x32/0x72), cotp_payload_bytes, direction and tier2_plugin_route="s7comm" — never the inner PDU (R-02 closure, COTPPayload). That opaque envelope is real OT-control evidence: it flows into asset.CMDB.ObserveControl (Domain:OT, Category:industrial) and classifies the S7 hosts as observed control assets, which is exactly the precondition the shipped flow gates require — so T0886 Remote Services / fan-out (already det), T0867 lateral-tool-transfer and T0869 beaconing fire on S7 sessions with no new detector and zero PDU semantics. The chain is pinned end to end, through the real decoder, by asset.TestS7Tier1EnvelopeFeedsOTAssetClassification. No new ICS detector was added — one would have duplicated the already-covered T0886 and added a false-positive surface; the anomalous-PDU-volume-burst signal is deliberately NOT built, because without PDU semantics a legitimate bulk read/download is the identical byte burst (the no-flood doctrine).
  2. NEXT — sandboxed customer/partner plugin (customer-driven, never enters the core). A customer who has captured their own S7 traffic — or a partner with an OEM relationship — ships a private decoder as a signed, capability-scoped plugin. It emits canonical ot/industrial/* events onto the one lake exactly like a built-in and feeds the same ics arm, but a GPL/proprietary/customer-private decoder never enters the permissive core.
  3. STRATEGIC — legal-reviewed clean-room of S7comm-classic (counsel-gated). Implemented under counsel sign-off via the two-person split. S7comm-classic's plaintext, no-TPM nature keeps the anti-circumvention axis low; the gate is source-provenance discipline plus a counsel review. S7comm-plus stays plugin/partner-only — its integrity protection raises the anti-circumvention exposure past what a clean-room native decoder should carry.

Revisit trigger: confirmed S7 demand in the backlog and counsel sign-off promote tier 3 for S7comm-classic; a partner/OEM relationship or a customer self-decode promotes tier 2 at any time; tier 1 is available the moment the passthrough substrate sees S7 traffic.

C.2 — Other Category-3/4 candidates (seeded entries, to be filled to S7comm's depth when demand arrives):

Protocol / vendor Cat Spec availability Tentative chosen path Note
MELSEC variants (Mitsubishi) 3 Partial — community docs + own captures legal-reviewed clean-room or plugin Provenance is the gating concern, not obtainability
GE-SRTP 3 Partial — community RE legal-reviewed clean-room or plugin Same posture as MELSEC
Vendor BACnet/DNP3 extensions 3 Partial — proprietary supersets of an open base native clean-room of the open base + plugin for the proprietary super The open base already ships; the extension is the row-3 delta
FINS (Omron) 1 (confirmed 2026-10-24wb) Obtainable — Omron publishes it (W227 FINS Commands Reference Manual; W342 CS/CJ/CP Programming Manual; W421 CS/CJ Ethernet Units Operation Manual) native clean-room — PERMITTED (the command-code tables are vendor-published facts, not community RE) Confirmed Category-1 — see the intake record below. Materially UNLIKE S7comm (C.1), where Siemens publishes nothing
Proprietary security/crypto formats; undocumented OEM control planes 4 None passthrough-only → sandboxed plugin → OEM partner The generic row-4 bucket; same tiered ladder as S7comm

C.2-FINS — OMRON FINS intake record (filled 2026-10-24wb). The one worked example besides S7comm, because FINS was built as a native decoder, reverted on a blanket license check alongside S7comm, and then confirmed Category-1 here and re-shipped. The six fields:

Field Record
Protocol / vendor FINS (Factory Interface Network Service), OMRON SYSMAC PLCs (CS/CJ/CP/NJ/NX). Transport: FINS/UDP and FINS/TCP, TCP 9600. Segment: OT (factory automation)
Spec availability Obtainable — OMRON publishes it. The MRC/SRC command-code tables, memory-area codes, and the FINS/TCP frame layout are documented in OMRON's own manuals: W227 FINS Commands Reference Manual, W342 CS/CJ/CP Programming Manual, W421 CS/CJ Ethernet Units Operation Manual. This is the decisive difference from S7comm (C.1), whose record reads "Siemens publishes no protocol specification."
Reference-impl licence Not needed. The decoder is written clean-room from OMRON's vendor-published command tables (facts / method-of-operation under the idea–expression dichotomy, §2.1), NOT from the copyleft community dissectors (Wireshark omron-fins = GPL-2.0). No GPL/LGPL source was opened while writing it; no dependency is added (conduit stdlib-only)
Jurisdictional risk Low. FINS-classic is plaintext with no technical protection measure, so the DMCA §1201 anti-circumvention axis S7comm-plus raises does not apply. Decoding a vendor-documented plaintext protocol for passive monitoring is ordinary interoperability (§2.2, Sega v. Accolade)
Demand OMRON is a top-tier PLC vendor in factory automation; FINS surfaces in any OMRON-heavy OT estate's raw-store signal
Chosen path Native clean-room — SHIPPED (decode/omronfins, 2026-10-24wb). Control-level only (MRC/SRC, read/write intent, program/mode/file direction, response end-code); memory-area payloads are never decoded. Feeds the shared ics arm with no new detector code, exactly like Modbus/DNP3/BACnet/IEC-104. No counsel gate — Category-1 obtainable-spec protocols ship native, the same tier as those four

⛔ The distinction that makes this sound, stated once: S7comm is Category-4 because no vendor spec exists — its only references are GPL/LGPL reverse-engineering, so a native decoder cannot prove clean provenance and the charter holds it to passthrough / plugin / counsel-gated tiers. FINS is Category-1 because OMRON documents it, so a decoder written from the vendor's own command tables carries the same provenance story as every other shipped OT decoder. Same session reverted both; only one comes back.

2.7.4 Part D — Category 5, opaque / encrypted on the wire

Recorded so it is never mistaken for a decode gap. When the bytes are encrypted the spec does not help without keys, so EventLake never pretends to decode ciphertext. The honest postures:

2.7.5 Part E — the intake process

  1. Trigger. A protocol enters intake from (a) the raw-store demand signal, (b) the sales pipeline, or (c) a partner/OEM offer.
  2. Classify. Place it in one of the five categories — the category determines the default legal answer. Most demand lands in rows 1–2 and proceeds with no legal call.
  3. For rows 3–4, fill the six-field intake template and record it in Part C. Row-3 entries follow provenance discipline; row-4 entries get the tiered ladder and a counsel review before any native implementation.
  4. Provenance discipline, always. Implement from the spec document, citing the section in comments. Never open a copyleft dissector while writing a decoder.
  5. Sign-off & logging. A row-1/2 decision is self-evident (the connector's spec citation is the record). A row-3/4 decision is logged with its chosen path and revisit trigger; a native implementation of a row-4 protocol additionally requires counsel sign-off.
  6. Revisit cadence. Every DEFERRED/GOVERNED record carries a revisit trigger. The decision is never frozen; it tracks the world.

2.8 Reference source-designs — the integration map

reference/source-designs/ holds four already-built products whose designs EventLake absorbed reuse-first — we adopt working code and designs, we don't rebuild. The originals are read-only provenance and are never edited; EventLake-native modules are built alongside.

Product (folder) What it is Maturity → EventLake subsystem
MFA (MFA-VERICHAIN) Multi-tenant MFA-as-a-Service (RADIUS/LDAP/TOTP/WebAuthn/push, billing) Most built — Go backend + Android/iOS apps (signed APKs), 13 migrations, live screens MFA (identity + auth/MFA) — and an Ingest source
Beacon (EASM) External Attack Surface Management Design + pure-Go scan-engine plan; portal live EASM (C24) → feeds CTEM (C44)
CLM (CLM) Certificate Lifecycle Manager Design (blueprints + portals) Certificates (C47 — PKI/cert lifecycle)
Sanchaar (Sanchaar) WhatsApp-equivalent E2EE messenger Design-first (handoff to implementation) Messages (C51 — secure collaboration)

What was reused, and the one seam that generalized. From MFA: the auth/MFA engine (Factor interface + multi-factor chaining, password + OTP + TOTP RFC 6238 + WebAuthn/passkeys + push number-match), the universal coverage rails (we are the RADIUS server; pam_radius, LDAP front-end, auth reverse-proxy/IAP, MFA-gated bastion), the mobile authenticators (offline TOTP, hardware-backed vault, biometric, push approval, Argon2id+AES-GCM E2E backup), and the multi-tenant spine patterns (deny-by-default RBAC, hash-chained admin audit, sealed secrets). From Beacon: the pure-Go scan-engine design (worker pool, tiered cadence T0–T3, operational discipline — PTR/FCrDNS, abuse SLA, opt-out, per-/24 rate caps, fingerprint-only) and the findings model (KEV/EPSS-centric vulns, the DNS/TLS asset graph). CLM is the design basis for Certificates; Sanchaar for Messages, re-scoped to bind to platform identity, guard with DLP, record to the evidence vault, and surface ChatOps.

The critical ingestion seam — and the doctrine it produced. MFA emits through a transactional outbox: an AFTER INSERT trigger writes auth_events into siem_outbox, a forwarder drains it FOR UPDATE SKIP LOCKED, and the Ingest adapter maps each row to a canonical it/authentication event. That shape became the "EventLake-native source" pattern: write to a durable local table in the same transaction, drain with SKIP LOCKED, normalize at the adapter — durable, ordered, broker-free. It also independently validated EventLake's federate-don't-centralize doctrine (Postgres handles tens of thousands of TPS; drop the broker for the small/edge tier). conduit/decode/mfa is built and tested against the real wire shape — the first EventLake-native source.

Resolved decisions. Repo layout: reference/source-designs/ is read-only provenance; no code migrates into the originals. Brand: fold under the umbrella — the originals keep their brands only as provenance. Permissive-only holds across the portfolio (verified on MFA's dependency set): no GPL/AGPL.

Two staleness corrections (fidelity rule — the newer source wins, and the disagreement is recorded). (1) This map names the identity subsystem "Persona"; that name is retired — the Go service is conduit/mfa and the subsystem is MFA. (2) It records the UI-font convergence as Manrope → Inter; the shipped Hearth stack has since moved the UI/body face to Plus Jakarta Sans (Fraunces display is unchanged). Both are logged as decisions D08-20 and D08-21 in §3 rather than silently rewritten, because the map is provenance and its history is part of the record.


3. Decisions (ADR register)

Every row records what was decided, when, what was rejected, and its status. Where two absorbed sources disagreed, the newer wins and the disagreement is recorded here rather than silently resolved.

# Decision Date Alternatives REJECTED Why Status
D08-1 Implement gRPC clean-room in the stdlib — HTTP/2 via net/http, hand-rolled length-prefixed framing, a small protobuf codec, grpc-status trailers 2026-06-27 google.golang.org/grpc + google.golang.org/protobuf Would break the zero-require core, force a separate module for one transport, pull a large transitive tree each needing SPDX vetting and a measured D4/capability justification, and add the protoc toolchain to the build. gRPC is three public specs, not a framework we must adopt BUILT — interoperates with real grpcurl/grpc-go/grpc_health_probe
D08-2 The analyst query grammar is SQL (conduit/query, POST /v1/query/sql) — the O6 resolution 2026-07-16 (a) a bespoke SPL/KQL-style pipe grammar; (b) "no grammar — NL + structured reads + saved queries suffice" SQL is the superset skill both migrating populations share; the W39 migration story composes onto it; NL needs something legible to compile TO. A bespoke grammar is pure switching cost with no differentiation BUILT (v1)
D08-3 v1 SQL is a single-stream SELECT subset — no JOINs, subqueries or window functions 2026-07-16 A full SQL surface The lake is ONE stream; join-shaped questions are served by worldline/correlate//v1/aggregate. Widen only on measured analyst demand BUILT, deliberately bounded
D08-4 tenant is not expressible in the grammar — rejected at compile as predicate, projection, grouping or order key 2026-07-16 Accepting and overriding it A grammar must not offer a knob the server would ignore. Tenancy is forced from the credential BUILT
D08-5 Implement MCP clean-room, stdlib-only, and take all three postures (server · client · securing an MCP estate) 2026-07-11 An SDK dependency; server-only; client-only JSON-RPC 2.0 over streamable HTTP is small enough to hand-roll (the gRPC precedent), and each posture unlocks a distinct class of value with zero new UI BUILT
D08-6 Propose, never execute on every machine/AI door — conductor.Propose downgrades even a chain-allow to a hold 2026-07-11 Letting a high-confidence AI action auto-execute like a human-proposed one A model-planned step is a hypothesis, not a fact. The outcome of an AI-originated action is only ever awaiting-approval or denied BUILT
D08-7 No model ships with EventLake; the seam plus a deterministic extractive default 2026-07-11 Bundling a default model Bring Claude, a local model, or an air-gapped model — EventLake governs whichever, identically; every test passes with no LLM at all, and unplugging the seam is the architectural kill switch BUILT
D08-8 Masking before egress, ON by default for any non-local endpoint, and a DNS name fails CLOSED as remote 2026-07-11 Trusting a resolver to prove locality; opt-in masking Locality is unprovable without trusting a resolver, and lake events carry PII/secrets. EVENTLAKE_LLM_UNMASKED_EGRESS=1 is the explicit, auditable opt-out BUILT
D08-9 The WASM/wazero plugin runtime is the licence-isolation keystone; the engine rides the perf tier, the trust spine stays in the core 2026-06-12 → 2026-07-19 Linking third-party decoders into the core; a native-plugin (.so) model; refusing closed protocols outright A sandboxed decoder is not linked, so its licence is its own concern — a GPL or proprietary decoder can run as a plugin where it could never be a built-in. This was the repo's first external dependency, signed off under D5 on a capability/licence-isolation argument, not a D4 perf number BUILT (core + engine + install surface)
D08-10 CapDecode only — the engine refuses any descriptor requesting network/fs/clock 2026-06-12 Shipping capability-gated host functions alongside v1 Fail-closed until a host-function surface is deliberately designed; CapDecode needs none BUILT; the host-function surface is deliberately ⬜
D08-11 Never-drop passthrough is the DEFAULT posture for unknown sources, not a fallback 2026-06-12 Dropping undecodable frames; a decoder-or-nothing ingest A protocol EventLake does not decode is a visible, un-parsed source — never a blind spot. It also generates the demand-ranked decoder backlog, a loop competitors without a never-drop substrate cannot run BUILT
D08-12 OCSF/ECS is the interchange; the canonical event stays the internal truth 2026-06-12 Adopting OCSF as the internal model Vendor field-mappings the ecosystem already writes become reusable both ways, without surrendering the internal shape detection is written against. The exact canonical class rides unmapped so a round-trip is lossless BUILT (bidirectional)
D08-13 OCSF export is pure serialization of already-masked rows 2026-06-12 A separate export path Masking, RBAC and the cursored page all apply unchanged — interchange must never become a second, weaker read BUILT
D08-14 The Platform flag is the WRITE gate for the five registries with no tenant dimension (Sigma single-event, YARA, intel, DLP datasets, Rules packs); reading stays canRead 2026-07-22 Leaving them at manage-detections (tenant admin) A row there matches every tenant's telemetry and shares ONE id-space, so writing it is an OEM act — and a tenant admin uninstalling a pack would unload a live detection out from under every other tenant. Install and uninstall move together BUILT; per-tenant registries are the recorded refinement
D08-15 Where a door accepts both kinds, the check reads the submitted document, not the route 2026-07-22 Gating the whole route at the higher bar A correlation/search payload is still any admin's; a single-event Sigma or YARA payload takes the operator. Gating by route would have taken authoring away from tenants who legitimately hold it BUILT
D08-16 Egress is deny-by-default in two independent namespaces — allow (recipients) and allow_sinks (forwarder sink hosts), each with its own unbounded flag and its own pattern language 2026-07-23 One merged allowlist A tenant bounded for mail and wildcarded for continuous egress is exactly the state one merged flag would report as "bounded". Permitting mail to a domain is not permitting a raw event stream to it, and an entry must never be read in a namespace its author was not thinking in BUILT
D08-17 Arming a standing egress takes tenant-ADMIN clearance, and the check is on the MERGED act 2026-07-23 Checking the incoming body only A POST by an existing id REPLACES — so disarming an armed schedule or redirecting its to is admin too: an egress a role may not arm is one it must not be able to retract. Firing one now stays act (it creates no standing order and reaches no new destination) BUILT
D08-18 The id space for tenant-scoped registries is (id, tenant) (forwarders, report schedules, correlation rules, ADF units, authored reports) 2026-07-22/23 A flat global id space with upsert-by-id Before this, an upsert by id silently replaced — and thereby destroyed — another tenant's egress. Registering at an id another tenant already uses now creates the CALLER's own record and touches neither BUILT
D08-19 A cross-tenant refusal answers 404, not 403 (ADF delete, held actions, fedsource, PAM holds, evidence export) 2026-07-22 403 with a clear reason A 403 discloses that the id exists and who holds it. No existence oracle — and the evidence-export refusal deliberately writes no audit either BUILT
D08-20 Backfilled history lands through the lake's Backfill door — sealed, WORM, persisted, with NO subscriber dispatch 2026-07 Replaying history through the normal ingest fan-out History is evidence, not signal: the SOC is never paged for years-old records and baselines never learn from them. A deliberate retroscan re-runs the rule layer hermetically when the operator wants the compromise-assessment answer BUILT
D08-21 .proto IDL is documentation, not a build input 2026-06-27 Generating from a .proto at build time The wire contract is the source of truth; field numbers are pinned in the codec functions. Keeping codegen out is what keeps the core zero-require BUILT
D08-22 gRPC listens on a separate listener, off by default (EVENTLAKE_GRPC_ADDR) 2026-06-27 Multiplexing onto the REST port gRPC needs its own TLS/h2 port; off-by-default keeps the default attack surface at one listener BUILT
D08-23 The hard line on distillation: using a frontier model's outputs to train a competing model outside a sanctioned mechanism is refused 2026-07-11 Unsanctioned teacher-student distillation It violates provider usage terms. Route any such ambition through a sanctioned service feature or a written agreement. We do not launder capability STANDING RULE
D08-24 Model weights are runtime data the customer pulls, never bundled into release artifacts 2026-07-11 Shipping recommended weights with the product The SPDX allowlist governs code we ship or link; a custom-licensed weight never enters our artifacts. A weight we would redistribute is a separate gate STANDING RULE
D08-25 A Category-4 protocol gets a logged, tiered decision record, never a blanket refusal 2026-06-13 "S7comm: no, on clean-room grounds" Scattered refusal notes are not a process. The tiered ladder (passthrough+COTP envelope → sandboxed plugin → counsel-gated clean-room) gives real value at every rung, and every record carries a revisit trigger INSTITUTED
D08-26 S7comm-plus stays plugin/partner-only, even if S7comm-classic is later clean-roomed 2026-06-13 Treating the family as one decision Integrity protection raises the anti-circumvention exposure past what a clean-room native decoder should carry. Classic (plaintext, no TPM) and plus are different legal objects GOVERNED
D08-27 Agent-authored automation is held to a STRICTER bar than the operator door — a synthesized playbook may not contain an InherentlyIrreversible() act, though a human-registered one may 2026-07-11 Parity with the human authoring door A human accepts the consequence at registration time and the ReversibilityGuard still holds it per run; an agent's draft has no such author. Asymmetry here is deliberate BUILT
D08-28 Agent runs are a FOLD of lifecycle telemetry, not a private run store 2026-07-12 A dedicated runs table The lake already is the record; a second store would need its own tenancy, masking and audit — a second, weaker door by construction BUILT
D08-29 OIDC/JWT on the management plane is deferred; the API-key model + the D2 cookie-session plane ship first (EventLake-as-OIDC-IdP exists for app federation, which is a different thing) recorded Shipping OIDC on the management plane first Honest deferral, recorded rather than implied. A previous federation overclaim was corrected to OIDC/JWT-only for app federation DEFERRED
D08-30 The reference integration map's "Persona" naming is superseded by MFA 2026-07 Rewriting the provenance doc silently The map is provenance; its history is part of the record, so the correction is logged here and flagged in §2.8 rather than erased CORRECTED
D08-31 The integration map's Manrope → Inter UI-font convergence is superseded by Plus Jakarta Sans for UI/body (Fraunces display unchanged) 2026-07 Leaving the older target in place The newer source wins under the fidelity rule; the disagreement is recorded rather than resolved in silence CORRECTED
D08-32 A parse rule is SELECTED by its Selector, and NAMED by a functional vendor_product join key — the name is never matched on 2026-08-29 (PROPOSED, owner to ratify) (a) Adding Vendor/Product fields to connector.Manifest — REJECTED: the manifest deliberately has none, and identity here is the emitted event class, not a vendor string (docs/01 §2.2.6). Vendor/product are LIFTED FROM THE PAYLOAD where the payload states them (the CEF routing does exactly this), never declared by the source, which cannot be trusted to describe itself. (b) Selecting a parser BY the OEM/product name — REJECTED as circular: you cannot match on an identity you have not yet assigned. Selection must key on what is actually on the wire. (c) Encoding vendor/product/version as parseable TOKENS inside the parser id (paloalto:panos:10.2) — REJECTED by the standing rule "A VALUE THAT IS A JOIN KEY HAS NO TOKEN GRAMMAR TO EXTEND" (docs/reference/wave-rules.md): a consumer taking path.Base or splitting on : breaks silently, with every test green. (d) Agent-side sniffing to pick the parser — ALREADY REJECTED at §6cw: the receiver ships verbatim and parses nothing, which is what lets parsing evolve without an agent release. The two halves are different questions and were being conflated by the phrasing "product name or tags". IDENTITY is a join key: lower_snake, functional, stable across rebrands (D00-15), e.g. paloalto_panos — it is stamped on the event, cited by rules and grouped by boards, so it must never be a marketing string, and it must stay OPAQUE (no grammar). SELECTION is the existing schemaread.Selector{Source, Hint, Label}, evaluated most-specific-first: an explicit operator binding (source/connector/port) beats a structural content marker (prefix-anchored, positive-only, the shipped routeEmbedded pattern) beats the RFC 5424 APP-NAME / RFC 3164 TAG — which 2026-08-29h made available on the BSD path for the first time — and everything falls through to the never-drop passthrough (D08-11). So the answer is BOTH, with the roles kept apart: product name in the NAME, tags/selectors for the MATCH. ⛔ Nothing here widens the parser-kind set: a user supplies PARAMS (a grok pattern, a dissect template, a mapping), never a parser body — a body means signed CapDecode WASM (D08-9/D08-10). ⛔ The one question this does NOT answer is §6cw option 2's "who may declare a sub-format"; if selection ever keys on a caller-supplied declaration rather than on content, that rule must be supplied first. PROPOSED
D08-33 A parse rule is SUPPORTED per DEVICE VERSION for as long as the OEM supports that version, and becomes LEGACY — no maintenance owed — when the OEM drops it. The version it was written against is a DECLARED FIELD, never a token in its name 2026-09-20 (owner-decided) (a) Mirroring EventLake's own 18-month commercial window for READING too — REJECTED, and this was the session's own first proposal before the owner corrected it: DefaultSupportWindow governs what we PROMISE TO MAINTAIN on our own artifacts; applied to a reader it would delete the ability to parse archived evidence. A SIEM's retention outlives every vendor's support window (PCI 1 year, several regimes 7), Lake.Reparse re-parses RETAINED raw, and schemaread never drops a byte — so removing a rule for an EOL format silently destroys forensic capability on data still on the lake. Same doctrine as shred accepting its retired token framing PERMANENTLY because those tokens sit inside sealed events and can never be rewritten. (b) A version token inside the rule id (paloalto_panos_10.2) — REJECTED by D08-32: a join key has no token grammar, and a consumer splitting on _ or . breaks silently with every test green. (c) One version-blind rule per vendor — REJECTED: PAN-OS column lists differ per major, and a version-blind list mis-assigns every field to its NEIGHBOUR, which reads as data rather than as an error The owner's reason is what makes LEGACY free: once the OEM stops changing the format, there is no more rework to do on our side — a frozen upstream means a frozen parser. ⭐⭐ The UNIT is a DEVICE VERSION, not a vendor (owner clarification 2026-09-20ae: "support parsers w.r.t varying versions as long as OEM supports device versions") — so a tranche's scope is every major the OEM currently supports, each by its own rule, which makes the vendor's LIFECYCLE page a required tranche input beside its format reference. The two states are ACTIVE (the OEM still supports that version; we track fields, chase new message types, fix defects) and LEGACY (the OEM dropped it; no maintenance is owed). ⚠ "We don't need to support it anymore" ends the MAINTENANCE obligation; the rule still ships and still parses, because deleting it strands archived evidence a customer holds under a retention regime — deletion is not a third state, and reversing that would need saying explicitly. A format whose fields moved between majors gets a SEPARATE rule with its own functional name, and the Selector decides which fires — never a name split. ⚠ Enforced mechanically rather than by prose: RuleSpec.SourceVersion declares what the rule was written against, and a guard refuses a version-looking token in a rule id — bound at ADMISSION (spine.vetOnboarding), deliberately NOT in RuleSpec.Compile, which also runs on the D22 replay fold whose caller discards the error, so a refusal there would silently stop an already-registered rule from parsing after a restart rather than reporting anything. A record already admitted is replayed, never re-judged — the same reason a LEGACY parser is never deleted. ⚠ Measured 2026-09-20ad: D08-32's lower_snake IDENTITY half is contradicted by this tree's own drafter ("onboard." + onboardSlug(source) fails it 5 times out of 5), so it is scoped to what the repo AUTHORS until D08-32 is ratified — see docs/reference/text-log-parser-program.md §8e DECIDED

4. Contracts & interfaces

4.1 The REST endpoint catalogue

The complete surface, carried without abridging. This table and ui_kits/portal/portal-api.js must stay in lockstep — the portal is a client of exactly these endpoints, and a drift between them is a broken board, not a documentation lag. All JSON, all prefixed /v1 unless shown otherwise; the Role column names the RBAC verb the door takes (open = unauthenticated by design, fleet = admin + the Platform flag).

Method & path Role Purpose
GET /v1/healthz open liveness + event count
GET /v1/i18n/catalog open the message catalogue (B6-I1, 2026-09-19b) in the locale the REQUEST asked for (Accept-Language, RFC 4647 lookup) — every id resolved through the fallback chain, Vary: Accept-Language; a pure function of the header and the build, naming no principal, so safe before sign-in. Rendered client-side by SAFEDOM.t/refusal (ui_kits/dom.js). ?locale=<tag> (2026-09-19h): a signed-in surface passes the tag its SESSION resolved (SAFEDOM.loadMessages(base, locale)); request-supplied, so the door still reads nothing stored — source query; an unshipped tag falls through to the header
PUT /v1/i18n/tenant-default tenant admin (canIssueCredential) {locale} — the tenant's DEFAULT language (B6 §4.2, 2026-09-19h), deciding for members with no preference of their own; "" clears. Under /v1/i18n so it is never licence-gated (the /v1/tenant family keys to identity administration). Returns the same view as GET /v1/me/locale
GET /v1/me/locale self the caller's stored preference, their tenant's tenant_default (both AS CHOSEN), the locale this request resolves to, locale_source (user · tenant · accept-language · default) and the shipped list. A stored tag the build does not ship is kept and does not decide — the source says which level did
PUT /v1/me/locale self {locale} — set or clear ("") the caller's OWN preference; nobody writes another person's. Shape-checked (letters/digits/hyphens, ≤ 35), not registry-validated. ⛔ Read only by signed-in doors about their own principal — never by an anonymous one (B6 §4.3 item 5)
POST /v1/ingest ship telemetry append canonical events (tenant AND observer stamped from the caller; ids/timestamps filled). Gated by canShipTelemetry = canIngest OR RoleSensor (2026-08-21i) — see §2.1.2
POST /v1/ingest/raw ingest append RAW connector frames ({connector, frames[]}) — the brain's decode plane parses them server-side (the Sensor's -collect-* edge-shipper leg); tenant-forced, D23-metered per frame, undecodable frames kept via passthrough + declared per-frame
POST /v1/capture ingest the body-capture door (assembly/capture.go): {content_base64, source?, filename?, media_type?, source_event?} — raw content bytes (an HTTP body, an email attachment, a dropped file) land in the bounded tenant-budgeted capture store (NEVER on the lake) and are scanned by the live YARA scanner (built-in library + runtime/ADF rules); each hit is a tenant-stamped category-yara match event (causal parent = source_event) through the normal alert loop. Response = the receipt: {digest, length, stored, truncated, matches} — sha256 of the STORED bytes (the kept prefix when truncated), re-hash to verify. 16 MiB request cap
GET /v1/capture/stats read the caller tenant's live footprint on the body-capture plane: {stats:{artifacts, bytes, evicted}} — live artifact count, live stored bytes, lifetime evictions. Tenant-scoped (per-tenant byte budgets, so one tenant's storm never shows on another's stats); surfaces whether the plane is at budget and evicting evidence
GET /v1/events read query the lake (params → store.Query); tenant-forced, role-masked, audited
POST /v1/query/sql read the analyst SQL surface (C8 · the O6 decision — conduit/query, doctrine docs/08-interfaces-and-extensibility.md#query-surface): body {"sql":"SELECT … FROM events …"} → columns/rows + the compiled-plan echo (push-down summary + residual post-filter — the show-the-compiled-query trust doctrine). Tenant FORCED (the grammar cannot express tenant); events read through the SAME masked+audited View path as /v1/events; bounded (50k scan / 10k groups, truncation DECLARED); compile errors are 400 with offset-anchored messages
GET /v1/alerts, GET /v1/cases read sugar over events (category=alert/case)
GET /v1/alerts/stream read the alert list's PUSH twin — Server-Sent Events (text/event-stream; conduit/api/alertstream.go, tech-stack W4, 2026-10-18d). The list's own gate and scope (canRead, the same ?tenant= rule and refusals, the same list filters; bearer OR session cookie) and the gRPC tails' per-tenant stream ceiling — ONE budget across SSE and TailAlerts/WatchAlerts, refused 429 quota.stream.concurrency_exceeded + Retry-After. Frames: id: an opaque resume cursor · event: alert · data: the SAME Event JSON the list serves per item (masked + pseudonymized by the same read). Last-Event-ID replays the alerts stored after that id (event-time order, ≤ 500; a malformed id is the list's 400 common.request.malformed_cursor), then goes live. A : hb comment every 25 s. event: overflow (data: {}) ends the stream when the bounded handoff queue overflows for a slow reader or a replay would exceed its bound — the client re-reads the list and reconnects WITHOUT Last-Event-ID. Re-authorised on the gRPC streams' cadence (30 s; 12 h lifetime cap): a credential that no longer passes, or now resolves to another principal or scope, ends the stream. Sends X-Accel-Buffering: no and is exempt from gzip, so neither a front nor the compressor holds frames. The ops portal's alert tail uses it (fetch + ReadableStream, so the bearer path works too) and falls back to the 5 s poll
GET /v1/aggregate?field=… read grouped counts (a dashboard panel) over the tenant's events
GET /v1/reports read the report/dashboard catalog: built-in templates + enabled connectors' dashboards (global) + the tenant's AUTHORED definitions (A9 report agent, human-released — tenant-scoped, no cross-tenant existence leak). gRPC ListReports + the MCP platform_report_run list identically
GET /v1/reports/{id} read render one report against the lake, tenant-forced (?since/?until window); resolves built-ins, connector dashboards, and the tenant's authored definitions
GET /v1/reports/{id}/render read C52 compose: the SAME render typeset as a downloadable document artifact — ?format=html\|pdf\|docx\|pptx (default html; unknown → 400) + the usual ?since/?until window. Deterministic typesetting (conduit/compose): same snapshot ⇒ byte-identical artifact, X-Aether-Digest: sha256:… echoes the digest so any stored copy re-verifies against a re-render; every render lands a sealed it/platform/report-artifact receipt (id/format/digest/size — never the bytes). html serves inline, pdf/docx/pptx attachment (pptx = the native-vector slide deck, ADR §9); tenant-forced, query-limited
GET · POST · DELETE /v1/reports/schedules[/{id}] (+ POST …/{id}/run) read · manage · arm-egress scheduled reports — register/list/remove a cadence ({id, report_id, interval_seconds, window_seconds, notify, to, format}; tenant FORCED) and run one now. A due schedule lands an it/platform/report snapshot and, when notify, delivers a digest through the comms Notifier. notify:true is a STANDING EGRESS and takes tenant-ADMIN clearance (canArmStandingEgress, 2026-07-23 — the clearance its siblings POST /v1/forwarders and a playbook notify step already take; 403 names the notify:false remedy). Because a POST by an existing id REPLACES, the check is on the MERGED act, not the incoming body: an analyst may author/replace/run/delete their own non-notifying schedules, but re-registering an ARMED id (disarming it with notify:false, or redirecting its to) and DELETE-ing one are admin too — an egress a role may not arm is one it must not be able to retract. The id space is (id, tenant), so authoring at the same id as a platform-wide ("") schedule is the caller's own registration, not a replace (404, not 403, to remove the platform's). POST …/{id}/run deliberately stays act — firing creates no standing order and reaches no new destination, and the forcing principal is recorded apart from the author. format (""|html|pdf|docx|pptx; unknown → 400) attaches the composed document to that delivery (email → a multipart/mixed MIME part; the sealed report-artifact receipt carries the digest — bytes never touch the lake). Blank format = the backward-compatible text-only digest. A notify:true schedule's to must be permitted by the tenant's recipient policy (see /v1/egress-policy) or registration 400s; the delivery seam re-checks every cycle, so a policy tightened after registration refuses the next run (report-delivery activity refused) rather than the schedule silently continuing
GET /v1/sources read source-health (last-seen + counts) for the NOC/SOC wallboard. Tenant-scoped (2026-07-22): a source name is the ingesting principal — both ingest doors force it to p.Who, and the enrollment authority mints that principal into a tenant — so this serves the caller's own sources. A Platform super-admin gets the fleet view: every tenant's, merged one row per source NAME with counts summed. Same scoping on /v1/sources/health, /v1/sources/undecoded, /v1/health and /v1/lexicon
GET /v1/lexicon read the governed field-name dictionary read (conduit/lexicon): {vocabulary} = the runtime lexicon.Coverage() (canonical fields / logical sets / explicit vendor aliases — comprehensiveness as a number, the ECS/CIM/ASIM-breadth answer), {sets} = the field-set names, {sources} = per-source governance rates the live parse registry folded from governed parses (schemaread.GovernStat: events · fields · governed · collided · a bounded ungoverned spelling→count histogram + overflow · a 0..1 rate), ranked most-ungoverned first — the ALIAS BACKLOG ranked by demand, the governance twin of /v1/sources/undecoded. ?fields=1 adds the full canonical catalog (a dictionary browser). {sources} is TENANT-SCOPED (2026-07-22 — the sharpest case of the source-inventory family: an ungoverned histogram lists raw field SPELLINGS out of that tenant's log content, so it is a description of a customer's data shape, not shared operational data). {vocabulary}/{sets}/{fields} are the platform's canonical dictionary and stay identical for everyone; a Platform super-admin gets the fleet-wide alias backlog. The old note here — "Platform-level like /v1/sources/health (a source spans tenants)" — was the refuted premise, not a design decision
GET /v1/maturity read SOC maturity/effectiveness report over a window
GET /v1/usage read the tenant's metered INGRESS volume over a window (meter.Usage — ingress events + raw bytes + per-stream breakdown; derived events shown, not billed); tenant-forced
GET /v1/metering/rate-card open the commercial plan catalog (meter.RateCard — ingestion ceilings TB/day, all apps free, no feature SKU; base_monthly in paise). The PUBLISHED PRICE LIST — an OPEN, no-auth route on the public subject allowlist (the anonymous marketing pricing page reads it; zero tenant data, identical for every caller). bill/invoice/fleet/plan stay gated
GET /v1/metering/bill read the tenant's rated bill over a window (D10 usage→rate): ?plan=·?since/?until → {plan, ingress_tb, avg_tb_per_day, ceiling, utilization, overage_tb, base_charge, overage_charge, subtotal} in paise. Window-averaged overage (§8 burst-smoothing). Plan defaults to the tenant's assigned plan (then growth). Unknown plan → 400. Tenant-forced
GET /v1/metering/invoice read the tenant's GST tax invoice for the window (D10 usage→rate→invoice): same params + ?intra=true (intra-state CGST+SGST vs inter-state IGST) → {lines[], subtotal, taxes[], tax_total, total} in paise. Plan defaults to the assigned plan. Unknown plan → 400. Tenant-forced
GET /v1/metering/fleet fleet OEM cross-tenant revenue rollup (?since/?until) → {bills[], summary{tenants, by_currency{CUR:{tenants, ingress_bytes, base, overage, subtotal}}}, assignments{tenant→plan}} in paise. Rates every assigned tenant; unassigned tenants are excluded. NOT tenant-forced — gated by canReadFleet (admin + the Platform flag); a customer admin gets 403. Not on the public allowlist
PUT /v1/metering/plan fleet assign a tenant to a plan in the durable store: {tenant, plan} → upsert (sealed it/platform/plan-assignment, replay-durable). Unknown plan / missing tenant → 400. Gated by canManagePlans (admin + Platform)
DELETE /v1/metering/plan?tenant= fleet remove a tenant's plan binding (it then drops out of the fleet rollup). 404 if no binding existed. canManagePlans
GET /v1/platform/tenants fleet OEM customer-onboarding registry: every provisioned tenant → {tenants:[{tenant, status, created, plan, credentials:[{id, who, role, audience, tier, created, revoked}]}]}. NEVER carries key material (only SHA-256 hashes are stored, and those stay internal). Gated by canProvisionTenants (admin + Platform)
POST /v1/platform/tenants fleet create a customer tenant at runtime (the durable registry — sealed it/platform/tenant-registry + tenant-credential events, replay-durable): {tenant, plan?, admin_who?} → 201 with the tenant's FIRST admin credential (role admin, tier admin) whose key appears exactly once in this response. Plan validated before create (no half-provisioned tenant). Invalid name (1–64 of A-Za-z0-9._-; platform/default reserved) → 400; duplicate → 409. canProvisionTenants
POST /v1/platform/tenants/{tenant}/credentials fleet mint an additional runtime credential under the tenant: {who, role, audience?, tier?} → 201 {id, who, role, audience, tier, created, key} (key shown once, hash-only stored). Role limited to the closed provisionableRoles set — admin\|analyst\|auditor\|ingestor\|subject (the refusal message is GENERATED from that set as of 2026-08-21i; the hand-written one still read admin\|analyst\|auditor\|ingestor after subject joined it, so the door refused a role while telling the operator it did not exist). ⛔ sensor is deliberately not in the set: an agent credential is always a signed, short-lived, revocable enrolment, never a pasted secret; a provisioned credential can NEVER carry Platform/BreakGlass (no escalation to the OEM plane). Unknown role/tier/audience → 400; unknown tenant → 404. canProvisionTenants
PUT /v1/platform/tenants/{tenant}/status fleet {status: active\|suspended} — suspension fails EVERY credential under the tenant closed at the auth path (nonpayment/offboarding/compromise); resume restores them. 400 unknown status / 404 unknown tenant. canProvisionTenants
DELETE /v1/platform/credentials/{id} fleet revoke one credential (stops authenticating immediately; the record stays listed as revoked — audit, never deleted). 404 unknown/already revoked. canProvisionTenants. Also the RELEASE VALVE for the tenant-side guard below: this door carries no last-admin refusal, deliberately, because that path also ends with the tenant unable to log in
POST /v1/platform/tenants/{tenant}/invitations fleet THE ACCESS GRANT (2026-08-09) — invite a customer's first admin instead of hand-carrying a key. {email, role, audience?, tier?, ttl_hours?, who?} → 201 {id, tenant, email, who, role, audience, tier, invited_by, created, expires, state, delivered, link, delivery}. The link is <EVENTLAKE_CONSOLE_URL>/invite#<token> and appears exactly once: the 256-bit token rides the URL FRAGMENT, which is never sent to a server, so it stays out of access logs, proxy logs and the Referer header. delivery is email ONLY when a mail backend accepted the message (and delivered flips with it), else manual — the platform will not claim a delivery it did not observe. ttl_hours is clamped to a 336 h ceiling (an unauthenticated bearer path into a tenant must not be immortal); default 72 h. Tenant must be REGISTERED (404) and ACTIVE (409 — suspension closes every door, so an invitation issued during suspension would be a door that opens itself later). Durable it/platform/invitation (D22 replay, hash only — the raw token exists in this response and the link, nowhere else). canProvisionTenants
GET /v1/platform/invitations fleet every invitation across the estate, ?tenant= filtering to one customer, newest first → {invitations:[…]}. Carries the server-computed state (pending\|redeemed\|revoked\|expired) rather than raw booleans, so no surface re-derives "expired" from a timestamp and none can disagree. Never carries the token or its hash. canProvisionTenants
POST · GET /v1/tenant/invitations, DELETE /v1/tenant/invitations/{id} act the CUSTOMER's own invite door — a tenant admin invites a colleague without the OEM in the loop. Tenant is FORCED from the principal and no path or query carries one. Two things this door refuses that its platform sibling allows: a caller-supplied tier is 403 (not ignored — a door whose safety is borrowed from a filter in another subsystem is one refactor away from being wrong), and a subject audience is 403 (an end-user app identity is enrolled through MFA, not invited). DELETE withdraws an unredeemed invitation; a REDEEMED one is 409, because the thing to revoke by then is the credential it produced. Cross-tenant id → 404, never 403 (a 403 is an existence oracle). canIssueCredential (RoleAdmin)
POST /v1/invitations/preview open "what am I accepting?" WITHOUT consuming the token — a redemption page that burned it just to render itself would destroy the invitation on a browser refresh. {token} → {tenant, email, role, expires, invited_by} and deliberately nothing more: it is reachable by anyone holding the token, so it must not become a read of the tenant's directory. Rate-limited on the demand-intake budget (the same public-ingress bucket as the lead form) and again in nginx. Three refusals kept DISTINCT: 404 unknown · 409 used/withdrawn · 410 expired — different facts for the person holding the link, and there is no enumeration risk to trade against (a 256-bit token is not guessable)
POST /v1/invitations/redeem open burn the token, mint the credential it ALREADY describes, and return a SESSION — never a key. {token} → 200 with the normal D2 cookie session plus {tenant, role}. Nothing about the credential comes from this request: tenant, role, audience and tier were fixed by an authorized inviter, so the worst a stolen token buys is the exact access an operator already decided to grant to that address, once, before it expires. Single-use is mark-then-mint (the invitation is burned under the lock BEFORE the mint, because mint-then-mark lets two concurrent redemptions of one token both succeed). The raw credential is discarded after the session is bound to it, so the redeemer never holds a bearer secret; the console's next step is POST /v1/operators/passkeys. 403 sso_required when the tenant enforces SSO (a local credential through a side door would be an unaudited way around its IdP) — and the token is spent even then, because an invitation that survives a refused presentation is a retry-until-policy-changes token
POST /v1/me/devices/enroll-token any authenticated + step-up self-enrolment — a signed-in person adds their OWN device (row P wave 1, api/mydevices.go). Mints a single-use 10-minute join token the new device trades at POST /v1/enroll for its own device-bound SVID; the app never holds a pasted secret. ⛔⛔ Every identity field comes from the PRINCIPAL, none from the request — subject, tenant and tier are read off the caller, so a person can only enrol a device for themselves and the device credential can never be broader than they are (before this, enrolledSubjectTier granted every enrolled device "verified", silently widening a consumer signed up at identity-only). ⛔⛔ A DEVICE MAY NOT ENROL ANOTHER DEVICE (403): step-up bounds a stolen session and bounds nothing for a bearer, so an SVID-authenticated caller is refused here — otherwise one compromised phone chains into permanent access. kind (2026-08-21i) selects WHAT is being enrolled: app (default — a phone or browser the person reads their own account on; every enrolment before this date) or agent (a machine running the endpoint agent, which SHIPS TELEMETRY about itself and carries RoleSensor). An unrecognised kind is a 400 naming both values, never a silent fallback to the default — a caller that asked for a capability must not be handed a credential that quietly lacks it. ⛔⛔ An agent enrolment is refused (403) outside a SELF-SERVICE tenant, and the gate is the TENANT rather than the role: the minting principal holds RoleSubject and cannot ingest at all, so this door breaks the file's own "never broader than the principal that minted it" rule and is confined to the one shape where the widening has nobody to widen against — a tenant the person created for themselves at POST /v1/signup, where every record is already theirs. In a provisioned tenant an employee's laptop shipping into the employer's SOC is an administrator's decision, taken at POST /v1/enroll/tokens. Returns {token, device_id, key_id, tenant, subject, tier, kind, expires_in}
GET /v1/reputation/{prefix} any authenticated the thin-client reputation lookup — k-anonymous by construction (api/reputation.go + intel/reputation.go, 2026-08-20h; the door the browser extension of §10.1 needs). The client hashes the NORMALIZED value (sha256, lowercase hex), sends the first 4 hex characters, and gets every matching FULL hash with kind/severity/confidence; it compares locally. ⛔⛔ We never learn which value was asked about — a "check this URL" endpoint accumulates browsing histories as a side effect of protecting people, and under the consumer line we are the CONTROLLER of that. ⛔⛔ TLP:CLEAR ONLY, and an UNMARKED indicator is NEVER published (tlpRank[""] equals CLEAR's rank, so the natural filter fails open: CLEAR is a positive licence to redistribute and an absent marking is not permission). The filter is at READ time, so a marking RAISED by a merge withdraws the indicator and a revoke stops it. ⚠ A malformed prefix is 400, never an empty 200 — a client that reads "no hits" out of a bad request fails open on every page. The payload publishes not_covered: no hit is not a claim of safety. ⭐ Normalization is per-KIND, so a client hashes BOTH the URL and its host and looks up each prefix. Authenticated: the prefix protects the person, the credential means a walk of the 65536 buckets has an owner — an ANONYMOUS variant would publish our coverage and is an OWNER call
GET /v1/me/devices any authenticated the person's OWN enrolled devices (row P wave 1, api/mydevices.go, 2026-08-20e). Rows carry device_id, key_id, server-computed current, the device's tier, kind (app|agent, always present — 2026-08-21i), status (active·expired·revoked) and not_after. kind is never omitted: a person deciding what to remove is choosing between a phone that READS their account and a machine that WRITES telemetry into it, and a field that disappears for the common case makes the uncommon one look like a rendering bug. Scope is a NON-EMPTY subject binding in the caller's own tenant — an unbound enrolment (agent, sensor, anything enrolled before 08-20d) has Subject == "" and must match NOTHING, so a subject-less principal is never handed the fleet. ⭐ Addressed by key id, the OPPOSITE of /v1/me/sessions and deliberately: a session id IS the cookie credential, a device key id authenticates nothing (the credential is a private key that never left the device). not_covered publishes what the list cannot see, WITHOUT a count — the number of unbound SVIDs would tell an employee-subject the size of their employer's agent fleet
DELETE /v1/me/devices/{id} any authenticated remove one of my own devices, with no admin in the loop — the operator door (DELETE /v1/enroll/svids/{id}) is canIssueEnrollment = admin, and a consumer principal is RoleAuditor, so the only person who owns the device could not remove it. {id} is the key id. Revokes at BOTH planes (auth gate + seal keyring) via RevokeSVID, audited under the PERSON's own id. ⭐⭐ NOT step-up gated while enrolment IS: the factor step-up would demand is often the phone being removed, so requiring it deadlocks the emergency the door exists for — minting WIDENS, removing NARROWS. Residual risk stated: a stolen live session can deny the person their devices, which recovery can undo, where a deadlock could not. ⛔⛔ A device may remove ITSELF and no sibling (403) — the mirror of "a device may not enrol another device". Unknown, somebody-else's and unbound-agent targets all answer the same 404
POST /v1/access-recovery open "I lost my device" — records an UNVERIFIED claim for an operator to judge. It creates a REQUEST and never access, and it answers identically every time (202, one fixed body) whether the tenant exists, the subject exists, the queue is flooded or the caller is fishing — anything else is an account-enumeration oracle open to the whole internet. Only a body that is not JSON at all gets a 400
GET /v1/tenant/access-recovery operator (canIssueCredential) the tenant's recovery queue. Every row is marked unverified (each field was typed by an anonymous stranger) and names its own tenant. ⭐⭐ A PLATFORM principal ALSO receives the ORPHANED queue — requests belonging to tenants with no administrator of their own (TenantOriginSelfService, i.e. a consumer's tenant-of-one from POST /v1/signup), with orphaned counted in the envelope and flagged per row. ⛔ Not every tenant's queue: a provisioned customer is its own controller and its own admin resolves its requests; the OEM inherits a queue exactly while nobody else can read it, and the widening is audited as a cross-tenant read
POST /v1/tenant/access-recovery/{id} operator (canIssueCredential) + step-up approve or deny. Approval issues an ordinary invitation to an address the operator types, never to the contact on the record — the requester must not choose where the restoring link lands. ⛔⛔ An ORPHANED approval restores into the REQUESTER'S tenant on the SUBJECT audience at TierIdentityOnly, not into the resolving operator's tenant as an operator: run the B2B shape against a consumer and recovery hands a member of the public an operator credential on the platform plane. A foreign id and an unknown id are the same 404
GET /v1/me/access-recovery any authenticated the HOLDER's read (row 180, api/meaccessrecovery.go, 2026-09-27jc) — requests that NAME the caller, keyed on the principal's own (tenant, who), the same pair the holder's push wake is addressed to; no parameter names a subject or tenant. Rows carry id (= the wake's ref_id), state, created, expires, resolved_at; the envelope pending, more, next, unverified:true. ⛔ Not served: the requester's note and contact (their own words — could be written to steer the holder), their source address (in the lost-phone case the reader is the thief and the requester the owner), and resolved_by. ?limit= 1..50 (default 20), ?after=<id>; a cursor not on the caller's own list is one 400 whether swept, unknown or someone else's
GET /v1/tenant/users read the tenant's OWN user directory (2026-08-09) → {tenant, users:[{id, who, role, audience, tier, created, revoked, self}], admins_live}. Before this, seeing who held keys to your own estate required a Platform key, so every joiner and leaver was a support ticket to the OEM. Revoked rows STAY listed (the incident question is "who had access"). self marks the row the caller is authenticated as — resolved from the credential auth used, not from a matching who. admins_live counts unrevoked operator-audience admins so a console can warn before a click. Tenant-forced; a ?tenant= is not consulted. No key material, no hash, no prefix. There is deliberately no POST — granting is an invitation, the only path that never produces a bearer secret for a human to carry. canIssueCredential (RoleAdmin)
DELETE /v1/tenant/users/{id} act a customer admin withdraws a colleague's access — the leaver step, now at the customer's response time rather than the OEM's. Three refusals, three different problems: not your tenant → 404 (a 403 is a cross-tenant existence oracle); the credential you are signed in with → 409 (exact, from the resolved credential id, so two credentials sharing a who are distinguished); the last live admin → 409 (else a tenant revokes itself out of its own console and the only way back is the OEM — the dependency this surface exists to remove). The last-admin count reads the durable REGISTRY only, so it is conservative for a tenant also holding boot/env admin keys — the correct direction for a lockout guard, with DELETE /v1/platform/credentials/{id} as the release valve. Records an it/security/access-denied-plane governance entry naming actor and target. canIssueCredential
GET /v1/platform/scope fleet the operator's TENANT DIRECTORY — what the ops console's tenant switcher is built from: the provisioned registry UNION the lake's populated shards → {tenants:[{tenant, registered, status, has_data, created}], count, all_tenants:"*", max_scope_tenants}. The union is the point: the registry alone hides tenants whose data predates it, the lake alone hides customers provisioned this morning — and registered vs has_data are the two states an operator most needs to tell apart ("that customer sends nothing" vs "there is no such customer"). registered:false marks a shard that is NOT a customer (the boot default tenant, the OEM's own platform records). Platform-gated: who ELSE is on this deployment is exactly what a customer must never read. canReadFleet
?tenant= on the scoped reads fleet THE CROSS-TENANT OPS PLANE (2026-08-03). A platform operator may point a scoped read at one customer (?tenant=<name>) or at the whole estate (?tenant=*); absent, every read stays forced to the caller's own tenant exactly as before. A non-platform principal sending it is 403 — refused, never silently narrowed back to its own tenant, because a silently-accepted parameter teaches a client a habit that becomes a data leak the day the gate moves; naming its OWN tenant is allowed so one client serves both audiences. Scoped today: /v1/events · /v1/alerts · /v1/cases · /v1/aggregate · /v1/assets · /v1/apm/services · /v1/apm/service-map · /v1/infra/hosts · /v1/infra/forecast · /v1/infra/machine-health. Every scoped response carries {scope: own\|one\|all, tenants[], tenant_count?, tenants_total?, truncated?}. Two mechanisms, and the difference is load-bearing: a RECORD read widens its query (Tenant:"" — each event carries its own labels.tenant, so a wider query returns more rows, never a wrong one), while an AGGREGATE fans out per tenant and labels each row — HostMetrics keys on (host, metric) and two customers' web-01 would otherwise collapse into ONE row holding one customer's value under a key the other also owns (measured: the naive version returned a single row and one tenant's reading vanished). Rows carry tenant on any non-own scope and omit it on an own-tenant read, so existing clients are byte-unaffected. The estate fan-out is capped at 200 tenants with truncated:true reported — never a silent cap. Every cross-tenant read is audited to the platform tenant as it/audit/access activity cross-tenant-read (actor · surface · tenants · truncated) — under a processor obligation, authorized and unremarkable are different claims. The write paths are deliberately NOT scopeable (/v1/analyze/*): they emit findings → alerts → cases, and a fan-out would author another customer's alerts from an operator's dashboard click
GET /v1/apisetu fleet the API Setu conformance harness's ONE boot read (conduit/apisetu, phases 0/1 of the verification-broker spec): {catalog_measured, publishers[], endpoints[], config{enabled, base, sandbox, attempts, timeout, env_base}, config_error?}. Deliberately works with probing DISABLED — an operator must be able to see WHAT would be probed, and against which base, before deciding to open egress to a government gateway; the disabled state carries env_base so the screen can say how to enable it instead of only that it is off. P4 (2026-08-03) adds revocation{env_var, source, loaded, crls[{file, issuer, this_update, next_update, entries}], summary[], error?} — carried on the BOOT read because the shipped state is an EMPTY bundle, and a board with no revocation panel would let "revocation is UNKNOWN for every document this node verifies" pass for "no problems". canOperateVerification (admin + Platform); not on the public allowlist
POST /v1/apisetu/probe fleet run a live availability probe: {path} or {publisher_id} (+ attempts?, parameters?) → the per-endpoint aggregate. The answer is a RATIO plus the per-verdict counts, never a boolean — this gateway fans requests across backends that disagree about which paths exist, so availability: 0.33 and divergent: true are real reportable states and a yes/no would be a coin toss dressed up as a status. A 404 is two different things and they are counted apart: the gateway's unrouted ("Page Not Found" — no route on the backend that answered) vs the publisher's no_record (route reached, publisher answered — a SUCCESSFUL probe). The path must be CHOSEN from the catalogue, never composed: it is the only caller-supplied component of the URL, so a free-text path would make this an SSRF. 400 unknown endpoint/publisher · 503 when probing is not configured (a statement about THIS deployment, naming the unset variable — never a 502 blamed on API Setu) · 502 gateway failure. Each run lands in the lake as it/platform/verify activity probe, tenant platform. canOperateVerification
POST /v1/apisetu/verify fleet the OFFLINE leg — verify an ISSUER's own signature over an uploaded DigiLocker-issued document, or normalise a Certificate XML. Accepts a multipart form (field file) or the raw body (?filename=); needs no API Setu access, no consent and no onboarding, and reaches no network. → {verdict, document{sha256, kind, size, signatures[{digest_matches, signature_verified, covers_whole_file, trust_anchored, anchor{anchored, root, evaluated_at, time_source, reason}, verdict, issuer?, signer_subject, signer_issuer, …}], incremental_updates, marksheet?}}. Appended content is reported as unsigned, never as covered. The per-signature verdict is FIVE-state — SIX since P4 — and no pair of them may share a UI treatment (2026-08-03, P1+P2+P4): altered (the bytes changed — the only accusatory verdict) · indeterminate (we could not check — explicitly NOT an accusation; a re-saved PDF lands here and is expected to be the commonest support case) · not_anchored (intact, but outside India's CCA hierarchy) · anchored_unknown_issuer (chains to a pinned CCA root, signer not enrolled) · anchored_known_issuer (+ signer is the issuer of record) · revoked_signer (intact, under CCA, and the CA has WITHDRAWN the certificate — not accusatory, the document may be genuine and predate the revocation, and it is UNREACHABLE until an operator loads a CRL bundle). anchored_unknown_issuer is load-bearing and must never be collapsed into either neighbour: a lawfully-purchased Class 3 DSC anchors perfectly and can sign anything, so anchoring alone establishes that the signer holds a CCA-issued certificate, NOT which organisation they are. anchor.time_source says which clock validity was evaluated against (claimed-signing-time — self-asserted, nothing countersigns it — or verification-time), because expired CCA roots are pinned deliberately so genuinely old documents still anchor. P4 adds two legs to every signature. revocation{state, crl_issuer?, this_update?, next_update?, checked_at, revoked_at?, reason_code?, claimed_signing_before_revocation?, reason?} — four states, and only not_listed clears a certificate: unknown (the SHIPPED answer, and explicitly NOT "not revoked") · stale (a covering CRL is past its own nextUpdate, so it cannot clear anything — though a serial LISTED on a stale CRL is still revoked, because staleness bounds what a list may DENY, never what it may ASSERT) · not_listed · revoked. ⚠ A claimed signing time can NEVER excuse a revocation — nothing countersigns it, so the party best placed to backdate one is whoever holds the stolen key; the claim is RECORDED (claimed_signing_before_revocation) and not acted on. That is the deliberate asymmetry with anchor.time_source, which DOES honour the claimed time, because expiry is the scheduled end of every certificate's life while a revocation is an event published because something went wrong. And timestamp_token / timestamp_verified — an RFC-3161 token is DETECTED, never honoured (timestamp_verified is always false: no timestamping-authority root is pinned, and unsigned attributes sit OUTSIDE the signature, so treating presence as attestation would let anyone upgrade their own document's evidence). The CRL bundle is loaded from EVENTLAKE_APISETU_CRLS at BOOT and never fetched at verification time (the offline leg must not come to depend on a government CDN, nor leak which document is being checked, and when). 413 over the upload cap (refused, never truncated — a truncated document would fail its own digest and be reported as tampering). The durable event carries NO person: the parsed marksheet goes to the CALLER in this response, while the it/platform/verify activity document record keeps only the content hash, format, verdict and the ISSUING ORGANISATION — not the subject, not even the filename (only its extension). canOperateVerification
GET /v1/apisetu/history fleet the harness's own runs read BACK OUT of the lake (?activity=probe\|document, ?limit=, default 100 / max 500) → {records[{id, time, activity, outcome, severity, subject, attributes}], count}. The architecture rule made visible: the board's history is a lake projection, not a list the prober kept, so a restart replays it and it cannot diverge from the audit trail. canOperateVerification
POST /v1/verify/document act (canRunVerification) THE SOLD TWIN of /v1/apisetu/verify (revenue line 3, P3 2026-08-03) — the SAME offline cryptography, run as a product: tenant-forced, entitlement-gated per FAMILY, capped, and recorded as a BILLABLE check under the caller's tenant. Accepts the same multipart (file) or raw body → {verdict, document{…}, family, cap{allowed, used, included, soft, …}}; the remaining volume travels back WITH the verdict so a quota is discoverable before it is hit. Three gates that do not substitute for each other: RBAC (an auditor is 403 — a read role must not be able to spend the tenant's committed volume, while it may still READ the usage), entitlement (verification service + family; a verify-basic tenant asking for Document-class is 403 naming what its tier DOES cover), and the cap (429 carrying the numbers, never a bare quota error). A file with nothing to verify is a 200 with an honest verdict and is NOT billed — a JSON API envelope or an unrecognised format did no verification work; an indeterminate verdict on a real PDF/XML is billable, because "we checked and here is why we cannot confirm it" is the answer the customer paid for. Our own failure (empty/oversized) is 400/413 and records nothing at all. Never on the public allowlist
GET /v1/verify/usage read the tenant's OWN verification meter over the current period (calendar month to date; ?since=&until= override): {usage{resolved, unresolved, by_family[]}, since, until, plan, entitled_families[], caps[], overage_allowed, issuers_enrolled}. The count is a LAKE PROJECTION, never a private counter — a customer on a commitment will dispute a number, and one that can be re-derived from the record settles it in minutes. unresolved is shown deliberately: what we did NOT charge for is part of the claim. The window is the SAME one the cap enforces, so a refusal can never contradict this screen
GET /v1/verify/bill · GET /v1/verify/invoice read the rated verification charge (base + Σ_family max(0, resolved − included) × per-check rate) and its GST tax invoice (?intra=true for the CGST+SGST split; ?plan= previews another package). Families rate on their OWN lines — a blended rate lets cheap Service-class traffic subsidise expensive Document-class traffic and destroys margin silently. A plan with no verification leg is 400 naming it, never a ₹0 bill that reads like a settled account. The invoice reuses the ingestion path's GST machinery unchanged but has its OWN charge lines: a line reading "N TB/day ingestion ceiling" would be a false statement to a customer who bought CHECKS
PUT /v1/platform/verification-overage fleet (canManagePlans) opt a tenant into (or out of) soft-cap billing: {tenant, allow} → {tenant, allow, changed}. Overage is hard-capped by default and fail-closed — a runaway loop on the customer's side must not be able to generate an unbounded bill — so this is the ONE door that lifts it, and it takes the OEM gate for the reason that decides every gate in this family: a subject who can lift the bound placed on it has not been bounded. Durable (it/platform/verification-overage → D22 replay, last-writer-wins); absence is the safe state, so losing the registry re-caps every customer rather than un-capping them. Even with it on, a family the plan prices at ZERO still refuses — serving an unratable check bills nobody
POST /v1/verify/aadhaar/qr · POST /v1/verify/aadhaar/kyc act (canRunVerification) THE OFFLINE AADHAAR LEGS (aadhaar spec P3, 2026-08-16d) — the Secure QR printed on every card/e-Aadhaar, and the share-code-protected Offline Paperless e-KYC ZIP a holder downloads from myAadhaar, both verified against pinned UIDAI keys with no gateway, no licence and no UIDAI call. QR takes JSON {payload} (the scanner's verbatim string); KYC takes multipart file + share_code + consent_ref. → {id, leg, route, verdict, reason, key_name, qr|kyc{…}, billable, consent_ref, consent_basis, receipt, cap, keys_pinned}. Its own FAMILY (aadhaar_offline), and that is not about cost — it reaches no gateway and costs CPU exactly like the document leg — but it is a statutory identity check, so granting it is a deliberate act rather than something a tenant inherits: it starts at verify-pro, because verify-basic is defined as the tier that touches no personal data. ⭐⭐ not-anchored IS NOT BILLED, and neither is unverifiable. Only verified and altered are — a pinned key vouched, or provably did not vouch, for those bytes. not-anchored says we do not carry the key, which is a statement about OUR staleness, and unverifiable cannot separate the caller's junk from a UIDAI revision we have not measured; billing either would charge a customer for our own gap. The consequence is worth stating: pre-P0, with the pinned set shipping EMPTY, this product bills NOTHING — and keys_pinned rides on EVERY result so a customer can tell a bad artefact from an un-pinned deployment without a support ticket. The e-KYC leg needs a consent_ref and the QR leg does not, and the asymmetry is not about data volume: a card is handed over in person, while a ZIP arrives asserting an authorisation we did not witness — the customer is the Offline Verification Seeking Entity under the Aadhaar Act and the platform records their attestation (422, not 403: a spent or withdrawn consent is fixed by collecting it again, a 403 is about the API key). ⚠ THE DURABLE RECORD NAMES NO PERSON — name, DOB, gender, address and photo go to the CALLER and nowhere else; the lake keeps the verdict, the deciding key, the family, the billable flag and a content DIGEST. 429 capped with the numbers · 503 the pinned key set failed to load (our fault, not the caller's request). Never on the public allowlist
POST /v1/verify/service act (canRunVerification) THE SOUTH API (revenue line 2, P5 2026-08-04) — one brokered check against a government publisher, made with the CUSTOMER's own API Setu credential (Shape 1: the publisher's logs and the subject's consent screen must name the customer, which is what makes the arrangement lawful). Body {path, parameters{}, idempotency_key} (or the Idempotency-Key header) → {id, result{route, outcome, reason, artefact_sha256, query_digest, query_fields, sandbox, credential}, replayed, artefact_available, receipt, cap}. The path is CHOSEN from GET /v1/verify/services, never composed — it is the only caller-supplied component of the outbound URL, so a free-text path is an SSRF aimed at whatever the brain can reach, and this door (unlike the ops harness) is customer-reachable. outcome is three-state and no_record is a BILLABLE SUCCESS — the publisher was reached and answered about the subject; a rejected credential, a malformed request and every gateway failure are indeterminate and not billed. Idempotency is REQUIRED: a repeat inside 24h returns the original result, does not dial the gateway again (a replay must not spend the customer's own API Setu quota) and does not double-bill; only a RESOLVED result replays, so a timeout cannot become the permanent answer to that key. Refusals are deliberately distinguishable: 400 unknown path / missing key / a self-asserted consent:true (spec §5.3 — a boolean lets the sender mark its own homework) · 403 tier · 409 Document-class needs a consent artefact (P6) · 424 the tenant holds no credential (there is NO fallback to the platform's own — that convenience is the ToU breach) · 429 capped · 503 probing not configured. One call, not N: the probe's ratio exists to measure a route; a verification asks about a SUBJECT, and asking three times would take their identifiers to the gateway three times
GET /v1/verify/services read the catalogue this tenant may call, per row {service, publisher, path, class, consent_required, callable} + its own credential slots. It declares its own gap: the measured directory covers the certificate collections, which are ALL Document-class, so service_class: 0 and every row is callable:false today — a Service-class row (GSTIN/Udyam/MCA) lands when the directory is measured, and none is invented
GET /v1/verify/{id} read one verification read back from the lake, tenant-scoped in the LOOKUP (an id is not a capability) → {id, time, result, receipt, artefact_available:false, artefact_note}. The artefact is returned ONCE, to the original submitter, and never retained (evidence retention is P6) — stated rather than implied
GET · PUT · DELETE /v1/platform/verify-credentials fleet (canProvisionTenants) the per-(tenant, publisher) credential vault. PUT {tenant, publisher, api_key, client_id, expires?} → {slot} — fingerprint, created, rotated, expiry, revoked; NEVER the value (spec §5.1), and there is no code path in the package that can return one (credentialFor is unexported). The secret is KMS-SEALED with (tenant, publisher) as AAD before it reaches the durable it/platform/verify-credential record — sealed rather than hashed because an outbound call must PRESENT the credential, not merely check it, so losing the root KEK costs the credentials. Platform-gated beside tenant provisioning: it is a provisioning fact about a customer and the value arrives in the body. A re-PUT is a ROTATION (same slot, new fingerprint, rotated stamped); DELETE revokes and the broker refuses it the same instant (404 on a second revoke). ⚠ Present ≠ usable — a lake restored without its root KEK shows every slot healthy while every call refuses, so the vault answers that question separately
POST /v1/verify/consents act (canRunVerification) P6 — the consent artefact a Document-class check carries, and the ONLY thing that replaces the refused boolean. {subject, purpose, services[], method, evidence_sha256?, collected_at?, expires_at?} → 201 {consent{ref, subject:<SHRED TOKEN>, basis, …}}. The subject is tokenised at registration and never echoed; the evidence itself is NOT stored (we are not the custodian of the customer's consent paperwork — a hash ties a dispute to the document they produce). It lands in the EXISTING consent ledger (conduit/consent, the one DPDP rights run on), so a withdrawal there closes this door on the next call with nothing for the customer to do, and the subject sees it on their own privacy screen. ⚠ basis is always customer-attested and that is the honest limit — a registered artefact is the CUSTOMER's attestation with evidence, not the subject's act witnessed by us; subject-presented needs the publisher's redirect ceremony and is declared, not built. An UNSCOPED consent is refused (an empty services permits none — the entitlement family axis's rule). 503 when there is no shred keystore: erasability precedes collection, so a platform that cannot promise to erase refuses to collect
GET /v1/verify/consents read the tenant's artefacts (subjects are tokens; after an erasure they render as a token that no longer resolves — the record that a consent existed survives, its subject does not)
POST /v1/verify/erasure approve DPDP §12 / GDPR Art.17 erasure = CRYPTO-SHRED (design §8.3): destroys the subject's per-subject key, so every token and every KEYED query digest minted under it stops resolving — while the audit chain stays intact and still verifies (the seal covers ciphertext that is now unreadable, not data that was removed) and the bill still says a check happened. Admin-only and irreversible. Response distinguishes key_destroyed:false (the obligation is recorded; there was no verification data for this subject) from a real destruction — a different fact, never rendered as the same one. The proof event names the KEY, not the subject, and a boot replay RE-destroys it, so a keystore restored from a backup taken before the erasure cannot resurrect the subject
POST /v1/verify/service with composite:true act the COMPOSITE (2+4): the offline signature check on document_base64 AND the live check, in one call, priced on its own family. → {offline_verdict, offline_route:"offline:signature", live{…}, live_route, agree, disagreement, receipt, consent_ref, consent_basis}. ⚠ AGREEMENT IS NOT A THIRD VERDICT — the two legs are reported side by side and never merged, because the case the product exists for (an intact signature over a record the issuer no longer holds) is neither pass nor fail: line 4 proves PROVENANCE, line 2 proves CURRENCY, and a single green tick drops whichever the customer needed. disagreement names the case in a sentence so a board does not derive it from two enums. The receipt carries both routes and both verdicts as non-disclosable claims — a holder must not be able to present the half that suits them. Billed ONCE as composite, never as offline+document
GET /v1/me/privacy canReadSelf the SUBJECT's own governance record, in ONE read (EventLake Authenticator "My data"): the tenant's regime profile + its subject_term, the full 8-right vocabulary each with supported + sla_seconds, plus the caller's own consents / corrections / grievances and erasure (absent when none on file — an absent obligation and a fulfilled one are different states). One read rather than four because a mobile screen composed of four round trips renders in four stages, each briefly claiming the subject has no consents. The subject is taken from the CREDENTIAL (p.Who) and there is no subject parameter to forge — that is the whole difference between these and the operator governance routes (/v1/consent, /v1/erasure, /v1/correction, /v1/grievance), which take a subject because an operator legitimately acts on others' records. A credential carrying no Who is 403, never defaulted to the empty subject (which would address the tenant's ""-keyed records and give every machine key one shared ledger). On the public subject ingress as an EXACT path
POST /v1/me/consents canReadSelf grant or withdraw the caller's OWN consent for a purpose: {purpose, categories?, granted, expires_seconds?} → 201 (grant) / 200 {purpose, status:"withdrawn"}. granted:false withdraws — one route, because from the subject's side it is one control (a switch), and splitting it would let a client grant and withdraw in an order the UI never intended. A withdrawal is recorded, not erased: the receipt stays on file reading withdrawn, because "I never consented" and "I withdrew" are different facts and the organisation is obliged to keep both. Subject from the credential; public subject ingress
POST /v1/me/rights canReadSelf FILE one of the caller's own rights requests: {right, field?, value?, reason?, detail?, category?} → 201. right ∈ correction (needs field) · erasure · grievance (needs detail; SLA taken from the regime, so DPDP §13 and GDPR get their own deadlines). It never FULFILS one — FulfillErasure/FulfillCorrection/ResolveGrievance stay operator acts behind the existing routes, because a subject asserting a right and an organisation discharging its obligation are two different records, and collapsing them would let the ledger show a duty as discharged on the strength of the request that created it. A right the tenant's regime does not confer is 400 (not filed — nobody would owe an answer); a right conferred but with no subject-side filing route yet (access/portability/object/restriction) is 501 with that said plainly, which is a different answer from 404 and from 400. Subject from the credential; public subject ingress
POST /v1/me/device/posture canReadSelf + a device credential + an active device-posture consent THE PHONE'S OWN APP-PERMISSION POSTURE (2026-09-27ml, api/deviceposture.go) — the Buoy app posts {schema:"eventlake.mobile.posture/1", collected_at, os, os_version, apps[]} (per app: installer, the system-stamped install initiator, system flags, watched requested/granted permissions, enabled accessibility / input method / notification listener, active device admin + policies, default SMS/dialer roles) → 202 {accepted}; the brain derives it/mobile/app rows (decode/mdm.EventsFromOnDevice) that the mobsec arm judges. ⛔ A credential that names no enrolled device is 403 me.device_posture.device_only — the device and subject come from the credential, never the body. ⛔ The consent is checked on EVERY report (403 me.device_posture.no_consent), so withdrawing it at any door stops the feed at the brain whatever an old app build keeps sending. A foreign schema is 400 me.device_posture.schema; over 2 MiB is 413 me.device_posture.too_large. Public subject ingress
GET /v1/platform/rights canWorkConsumerRights (admin + platform) THE CONSUMER-RIGHTS DESK (2026-08-22b) — every erasure · correction · grievance obligation of every tenant that has no administrator of its own (assembly.TenantOriginSelfService), which is the queue POST /v1/me/rights was filing into and nobody could read: the discharge routes below are all scoped to the caller's own tenant, and a consumer's tenant-of-one holds exactly one principal — the person who filed. → {count, obligations[], outstanding, overdue, tenants[], not_covered[]}. Each row carries right · tenant · subject · status · outstanding · filed · due? · overdue plus its plane's own fields. ⛔⛔ due is ABSENT, never zero, where the regime states no deadline (PIPEDA/HIPAA confer no erasure right, and the retention sweep creates obligations regardless) — a zero time renders as year 1 and would draw every such row two millennia late. A grievance publishes its own DueAt, fixed when raised, rather than a recomputed one (re-deriving would re-date every open grievance the day a regime changed). Discharged rows STAY, with their outcome. Sorted overdue → soonest → no-deadline: the sort IS the prioritisation. Every read emits an it/audit/access cross-tenant-read naming the operator
POST /v1/platform/rights canWorkConsumerRights (admin + platform) DISCHARGE one such obligation — the controller's half of the same vocabulary the subject files in. {right, tenant?, subject?, field?, id?, to?, resolution?}. erasure/correction name tenant+subject (+field); grievance names only id and to ∈ acknowledged|resolved (the tenant is read OFF THE RECORD — nothing the caller sends can point the act elsewhere) and a resolution note is REQUIRED to resolve. ⛔ It never CREATES a right: a right is filed by the person it belongs to. 409 where the tenant HAS administrators (their duty, still theirs — not 403, the caller is entitled to this desk); 404 for a grievance that is unknown OR foreign, indistinguishable so the door is not an id oracle; 404 where no such obligation is on file. → {right, tenant, subject, status:"fulfilled", changed} — changed:false is an idempotent repeat. Emits a cross-tenant-act audit naming the operator, because the ledger's own governance event records the tenant and subject and not who closed it
POST /v1/leads/demo open the DEMAND INTAKE — the public site's "Book a demo" form (§12-7), and the ONLY open write on a public hostname. {email, phone, phone_country?, company, name?, consent, consent_text, source?, page?, plan_interest?} → 201 {reference, received} and nothing else (echoing the contact details back would make an open endpoint a reflector). Bounded on five axes: per-IP + global token bucket (429 + Retry-After + an it/security/access-denied record on the public ingress), a 16 KiB body cap, validated/bounded fields (email + phone are refused when over-length, never truncated — a shortened phone number is somebody else's), a 24 h duplicate window that returns the SAME reference, and a live-record ceiling (429, never a silent drop). Consent is a PRECONDITION: no affirmative answer or no consent_text → 422 (a distinct code, because the fix is to tick the box, not to correct the JSON). Contact PII is stored as per-lead shred tokens, so a keystore failure refuses the write (503) rather than storing something un-erasable. Reachable on the MARKETING hostname only (deploy/nginx/eventlake.conf apex/www block, rate-limited there too); deliberately not on the api. dataplane allowlist — no agent or app ever posts a lead
GET /v1/leads fleet the lead inbox + pipeline header (§11-6a/§11-7a): ?status= → {leads:[{id, reference, company, email, phone, phone_country, name, source, page, plan_interest, status, owner, tenant, consent_text, consent_at, received, updated, erased, age_seconds}], count, stats{total, erased, by_status, unowned, oldest_new_seconds}, notify{configured, flooding, cap, window_seconds, to?, window_ends?}}. Newest-first; age_seconds/oldest_new_seconds come from the SERVER clock (age is the signal the board is judged on). PII is resolved through the lead's DEK; an erased lead's fields read [erased]. canProvisionTenants (admin + Platform) — a CRM read is cross-tenant, so a customer admin gets 403. ⭐ notify is a SIBLING of stats, not a member (2026-08-23ak, §13 row 59): stats is pipeline health, whether anyone is being TOLD is a property of the deployment. It answers the two states the board cannot otherwise derive — unconfigured (every lead arrives silently and the screen looks identical to a healthy plane) and flooding (past cap per window_seconds the plane deliberately goes quiet, so this board is the only complete record). ⛔ to is present ONLY when configured and window_ends ONLY while a window is open — omitted, never zero-valued, because a zero time renders as 1970 and a board cannot tell "no pause" from "paused since the epoch". ⛔ A client must draw NOTHING when notify is absent (an older brain has said nothing, not "nobody is notified"). The read is deliberately NON-MUTATING: the flood window rolls lazily on arrival, so a getter sharing that path would reset the latch when an operator merely looks
GET /v1/leads/{id} fleet one lead by id or by reference (an operator reading LD-… off a phone call should not have to translate it) → {lead}. 404 unknown. canProvisionTenants
PATCH /v1/leads/{id} fleet the operator edit: {status?, owner?, plan_interest?, tenant?} (absent = leave alone, so a status move never clears an owner). status ∈ new\|contacted\|qualified\|demo-booked\|trial\|converted\|closed-lost (else 400); tenant records the §11-7b provisioned-tenant hand-off. Email, phone, name and the consent artefact are NOT editable — the prospect's own words are the evidence of the basis they may be contacted on. A status change and a tenant hand-off each append a PLATFORM-generated timeline entry. 404 unknown; 409 on an erased lead (a tombstone is terminal). canProvisionTenants
POST /v1/leads/{id}/convert fleet THE SIGNUP → TENANT BRIDGE (2026-08-09, §11-7b links 1→2). Before it, leads and tenants were unconnected planes and lead.Tenant was a field an operator typed. ONE authorized act: {tenant, plan?, role?, who?, ttl_hours?} → 201 {tenant, status, plan, invitation{…, link, delivery}, lead, lead_joined}, having created the tenant, bound the plan, invited the lead's OWN address as first admin, stamped lead.Tenant, moved the lead to converted and written the §11-7c system timeline entry. It mints no key — POST /v1/platform/tenants returns a raw admin key in its body and this deliberately does not, because the human who needs that access is at the far end of an email address. tenant is REQUIRED and never derived from the company string (a permanent identifier invented by a normalizer, whose collision case is another customer's live estate). Everything validatable is validated before anything is written; past that the order is tenant → invitation → lead stamp, because each partial result is repairable on doors that already exist, and the reverse leaves a lead marked converted pointing at a tenant that was never created. 409s: already converted (returns the tenant it points at — without this guard a retry provisions a SECOND tenant for one customer), an ERASED lead (its email is gone by decision; inviting [erased]@… would silently defeat the erasure), a colliding tenant name (never silently reused — that would be a cross-customer access grant performed by an autocomplete). lead_joined:false reports the designed state where the tenant is live but the CRM stamp missed. canProvisionTenants
GET · POST /v1/leads/{id}/activity fleet the append-only timeline (§11-7c): POST {kind, text} with kind ∈ note\|call\|email → 201 (the system kind is deliberately NOT offered — a platform-generated entry must come from the platform, or the timeline stops being evidence). Bounded at 500 entries per lead (429 beyond). GET → {activity[], count} oldest-first. 409 on an erased lead. canProvisionTenants
DELETE /v1/leads/{id}?reason= fleet DPDP §12 / GDPR Art.17 erasure = CRYPTO-SHRED (§11-6c), not a delete: destroys the lead's data-encryption key, so the sealed events stay and keep verifying while email/phone/name render [erased] everywhere (read model, segments, lake backups) and the operator timeline is dropped. The business record (company, consent artefact, status history) survives. Records + closes the obligation in the consent ledger, and the emitted proof event names the destroyed key GENERATION so boot replay re-destroys it against a keystore restored from a stale backup. canProvisionTenants + step-up (irreversible — a stolen live session must not be able to do it). 404 unknown
GET /v1/egress-policy read the recipient bound GOVERNING the caller's own tenant (its own policy, else the estate-wide default): {tenant, configured, policy:{tenant, allow[], unbounded, allow_sinks[], sinks_unbounded, set_by, updated}}, or {configured:false, allow:[], allow_sinks:[], note}. Two lists, two namespaces (2026-07-23): allow bounds RECIPIENTS (a report/notify to), allow_sinks bounds forwarder SINK HOSTS — each with its own unbounded flag, because a tenant bounded for mail and wildcarded for continuous egress is exactly the state one merged flag would report as "bounded". Deliberately tenant-VISIBLE (a refusal support cannot explain is worse than the refusal) and read-only; the answer derives from the caller's forced tenant, so no other tenant's policy is disclosed
GET · POST /v1/platform/egress-policy, DELETE /v1/platform/egress-policy/{tenant} fleet the per-tenant allowlist bounding destinations a TENANT principal typed for platform-performed egress — a report schedule's to, a playbook notify step's to. POST {tenant, allow:[…], allow_sinks:[…]} upserts BOTH halves in one transition (durable it/platform/egress-policy, D22 replay-rebuilt; re-validated on replay so a segment edit cannot install a pattern the checker never intended); set_by is FORCED from the caller (no body field). {tenant} = _default addresses the estate-wide ("") policy; DELETE returns 404 when none existed. Pattern language: "*" (any recipient — must stand ALONE) · @example.com (that domain exactly) · @.example.com (that domain + subdomains) · alice@example.com (exact) · soc-oncall (a literal channel handle); globs and CRLF → 400. Sink pattern language (2026-07-23, deliberately NOT the recipient one — an entry must never be read in a namespace its author was not thinking in): "*" (any collector — must stand ALONE) · siem.acme.example (that host exactly) · .acme.example (that domain + subdomains; the leading dot makes the test a suffix, so evil-acme.example cannot match) · an IP literal, IPv6 written UNBRACKETED and compared as an ADDRESS. A scheme, port, path, @ or glob → 400: the checker only ever sees a bare host, so accepting a URL-shaped entry would install a rule that matches nothing while reading like a bound. DENY BY DEFAULT, both halves independently — a tenant with no policy permits no authored recipient (so POST /v1/reports/schedules with notify:true 400s and an already-registered schedule lands a report-delivery refused) AND no sink destination; a policy carrying allow but no allow_sinks permits NO forwarder, because permitting mail to a domain is not permitting a raw event stream to it. Gated by canManageEgressPolicy (admin + Platform): a tenant that can edit its own bound is not bounded
POST /v1/analyze/flows, /v1/analyze/fraud act run a windowed correlation; findings ride the normal alert→case loop
POST /v1/analyze/ueba act run UEBA correlation (impossible-travel / privilege-escalation / password-spray / baselines / first-seen-geo / off-day). N6: when a geo resolver is installed (EVENTLAKE_GEOIP → assembly.WithGeoResolver), records carrying only a source IP are geo-enriched at extraction, so the travel detectors see IP-only telemetry
POST /v1/analyze/itdr act run the identity-threat (ITDR) chains over the tenant's it/authentication + it/iam + it/ueba/* streams — ato-chain (a ueba takeover-precursor finding then a persistence action by the same account within the chain window; T1078→T1098; Critical), consent-grant-abuse (risky data scope + offline_access, or a multi-user consent campaign for one app; T1528), token-sprawl (a durable-credential minting burst by one actor; T1098.001), nhi-interactive-login (a source-typed non-human identity in an explicitly interactive login; T1078.004). Findings carry a structured Reason and ride the normal alert→risk→case loop
GET /v1/identity/nhi read the non-human-identity inventory read-model (the itdr arm's projection) over a window: every SOURCE-TYPED machine identity (service account / service principal / managed identity / bot) with its behavior fold (events/logins/interactive_logins/credential_mints/distinct_sources/privileged), risk markers[] (interactive-login · multi-source · privileged · credential-burst), a 0..1 risk and a low/medium/high band, plus {total, by_type, by_band, at_risk} rollups. total==0 is the keep-the-mock signal
POST /v1/analyze/aispm act run the AI-SPM chain (N11) over the tenant's it/ai/* + it/llmsec/* streams — injection-to-action (an llmsec prompt-injection/jailbreak finding for a principal followed by a TOOL INVOCATION from that same principal within the chain window, default 30m ⇒ the confused deputy REALIZED; Critical, LLM01:2025). Conjunction-gated: a clean agent's tool use and an injection against a non-agentic assistant never fire. Findings carry a structured Reason and ride the normal alert→risk→case loop
GET /v1/posture/aispm read the AI-SPM posture (N11) over a window — the customer-facing AI-security read: the AI-ASSET INVENTORY folded from it/ai/llm-request + the NEW it/ai/tool-call stream (inventory{models[ref,provider,requests,tokens,agents,apps], agents[ref,requests,tool_calls,denied_calls,tools[],agentic,markers[]], tools[ref,category,calls,denied,agents], requests, tool_calls}), per-agent GOVERNANCE markers (ungoverned — agentic but absent from the NHI identity inventory · policy-denied · injection-alerted) with {ungoverned, policy_denied, injection_alerted} rollups, the windowed AI alerts{total, by_technique} (LLM*/AML* + llmsec/aispm rules only), and the assurance[] OWASP-LLM/ATLAS/Agentic (OWASP-ASI) coverage summaries from the framework engine (Agentic joined 2026-07-11 — the agentsec arm ships its content). inventory.requests==0 && inventory.tool_calls==0 is the keep-the-mock signal
GET /v1/shadow-ai read the shadow-AI discovery inventory (SR11, 2026-07-21) over a window (?since&until, RFC 3339; absent = lifetime) — the UNSANCTIONED-AI governance read llmsec/AI-SPM cannot make (both see only the sanctioned it/ai/llm-request gateway): the dns / TLS-SNI / HTTP it/network/l7 telemetry the DPI path already lands, matched against the curated multi-provider AI-endpoint catalog (conduit/shadowai; ~40 services, global; label-boundary suffix + one-label-wildcard match — zero-FP by construction, a row is a fact) → {rows:[{client, provider, categories, domains, streams, requests, first_seen, last_seen, via_gateway, evidence≤8}], providers:[summary], clients, requests, shadow}. The tenant's gateway stream marks providers via_gateway — a provider in use that the gateway never carries reads as SHADOW (shadow counts them). A pure read: no alerts, no events emitted; rows:[] is the honest empty state. Portal board SCREENS.shadowai (Protect)
POST /v1/analyze/agentsec act run the agentic-mesh security arm (W3, docs/02-analytics-plane.md#agentic-mesh-security) over the tenant's it/ai/{agent-message,agent-lifecycle,hitl,tool-call} streams — the ELEVEN OWASP-ASI kinds pivoted on the CONVERSATION: mesh-injection (T12) · taint-cascade (T05, Critical fan-out ≥3) · rogue-agent (T13, Critical zombie / High unregistered) · hitl-fatigue (T10, flood + rubber-stamp) · code-attack (T11, exec-payload idioms in tool args) · coordinated-injection (T14, human fan-out ≥2 agents) · impersonation (T09, claimed≠verified_from) · scope-violation (T03, declared-allowlist breach) · provenance-gap (T08, mid-stream attribution drop) · evasive-retry (T07, denied-then-hammer) · human-manipulation (T15, credential/OTP solicitation). Telemetry-gated (no roster ⇒ rogue/scope silent; no HITL ⇒ fatigue silent — an estate without mesh telemetry gets ZERO noise); findings carry masked material only + a structured Reason and ride detect.AgentSecRule → alert → risk → case. Returns {count, findings}
GET /v1/posture/agentmesh read the agent-mesh posture (W3 §6) over a window — the Synapse Mesh-panel fold: conversations[] (id · agents · messages · tainted — ≥1 injection-marker hop · apps), the agent roster[] (agent · status registered\|terminated\|observed-only · declared tools · apps · last_seen), the hitl[] approver health (requests · approvals · denials · median_latency_ms — the rubber-stamp gauge), and the stream counts {messages, lifecycles, hitl_events, tool_calls} (all zero = the keep-the-mock signal). A pure projection — nothing stored, nothing emitted, no raw message content
POST /v1/analyze/visionsec act run the computer-vision security arm (Argus) over the tenant's it/vision/* detection metadata — weapon / restricted-zone intrusion / loitering / deepfake-feed-integrity; the biometric-match verdicts on the same stream are handled by /v1/analyze/fraud (a mismatch → Impersonation)
POST /v1/analyze/mobsec act run the mobile-endpoint threat arm over the tenant's it/mobile/{device,threat} MDM/MTD stream (decode/mdm — Intune managedDevice · Android Management securityPosture · the Lookout/Zimperium/Jamf-Protect MTD threat family) — the FIVE verdict-keyed kinds that opened the on-device half of ATT&CK Mobile: compromised-device (jailbreak/root/COMPROMISED, T1404, High) · malicious-app (per-classification T1417 spyware / T1582 premium-SMS / T1407 trojan-dropper; one finding per device+app) · network-attack (MITM/rogue-AP, T1638, High always) · config-threat (device-admin T1626.001 / rogue profile T1632.001) · defense-tamper (MTD agent tampered/deactivated, T1629.003). Near-zero-FP (the management plane's OWN verdict is the gate — a compliant device never fires); hardware identifiers never modeled (the decode/mdm privacy doctrine); consent-gated in RunAnalytics as worker monitoring (purpose mobile-endpoint). Findings carry a structured Reason and ride detect.MobSecRule → alert → risk → case. Returns {count, findings}
POST /v1/analyze/biometric-governance act run the I1 biometric-governance arm (Argus cluster I — CV governance) over the tenant's it/vision/biometric processing events — cross-references each against the consent ledger and surfaces a breach as an it/governance/consent-violation (→ ConsentRule → alert → case): a biometric template processed despite an active right-to-erasure (Critical), and (when the tenant declared a lawful biometric purpose) a biometric verification with no active consent (High). Biometric data is sensitive PII (DPDP / GDPR Art 9); masked metadata only, never a template. Returns {count, violations}
GET /v1/posture/hardening read CIS/STIG compliance posture (harden.Report) over a window — controls compliant vs drifted, per asset/benchmark, + the drift worklist
GET /v1/posture/score read the unified posture score (posture.Score) over a window — the CNAPP "security score" that COMPOSES the host-CIS/STIG, cloud-CSPM, Kubernetes-KSPM, and vulnerability sub-postures into one weighted {score 0..100, prev, delta, band A–F, label, domains_assessed, domains[]} with a week-over-week trend + an explainable per-domain breakdown (weakest-first). Only domains actually assessed contribute (a tenant with no k8s isn't scored 0 for KSPM); domains_assessed==0 (no posture data) is the fallback signal to keep a representative view rather than render a fabricated 0/100. A rollup ACROSS /v1/posture/{hardening,cspm,kspm,vuln}, not a new signal
POST /v1/analyze/pki act run the crypto-estate posture arm (SR13-P1, the Certificates observe-half) over the caller tenant's it/pki/certificate inventory — expired / expiring-soon / weak-algorithm / self-signed-in-prod findings as it/pki/finding events; the urgent tier (weak-algorithm, or expired on a prod/crown-jewel asset) routes via detect.PKIRule → alert → case. Returns {count, findings}
POST /v1/analyze/tls-sessions act run the crypto-estate session leg (SR13-P2) over the tenant's lifted TLS handshake metadata (it/network/l7, activity tls) — deprecated-protocol / weak-cipher / quantum-vulnerable / downgrade-anomaly findings (the fleet-corroborated PQC-downgrade gate) as the SAME it/pki/finding stream. Returns {count, findings}
GET /v1/posture/pki read the crypto-estate posture + PQC-readiness scorecard (SR13-P1) over a window: certificate counts, expiring/expired/weak/self-signed exposure, worst-exposed assets, and pqc{quantum_safe, quantum_vulnerable, unknown, mosca_at_risk, readiness_pct, threat_horizon_year, migration_years} (Mosca: migration-time + data shelf-life vs the CRQC horizon — the harvest-now-decrypt-later prioritizer). A pure rollup — never writes
POST /v1/posture/pki/snapshot act the deliberate readiness-snapshot door (SR13-P4): computes the same posture AND lands it as a trendable, tenant-stamped it/pki/posture snapshot (flat scalar scorecard). This snapshot's presence is exactly what the migration-clock regimes (cnsa-2.0 / nist-pqc-transition) read as evidence the PQC-readiness assessment RUNS — act-gated and separate from the GET so a dashboard poll never writes. Returns the full posture report
GET /v1/posture/dlp read the data-security posture (classify.PostureReport) over a window — the Warden DLP-board read that folds the it/classification/scan stream (scan volume + max-tier mix by_tier, per-identifier counts JOINED with the live detector table's tier/validation/jurisdiction/regime metadata — the enrichment a raw scan event never carried, plus the multi-regime regimes[]/jurisdictions[] compliance facets and channels[] provenance) together with the it/enforcement/dlp-egress stream (conductor-gated verdicts egress{total,quarantined,held,blocked,recovered}) and the protection estate (armed identifier/secret detectors + EDM/fingerprint dataset names+sizes — salted digests only, raw never stored). scans==0 && egress.total==0 is the keep-the-mock signal
POST /v1/posture/dlp/datasets platform operator (canManageDLP = admin + Platform) the DLP manage leg (9d-2): upload protected data into the live classifier's EDM / fingerprint indices. Body {kind:"edm"\|"fingerprint", dataset, values[]?, doc_id?, text?} — edm indexes each exact record in values[]; fingerprint winnows text under doc_id. The raw values/text are DIGESTED server-side and DISCARDED — only salted digests / winnowed hashes are kept and persisted (it/platform/dlp-dataset, replay-rebuilt), so the protected PII never lands on the lake. A NEW dataset is minted from the deployment EVENTLAKE_DLP_SALT (422 if none configured; records may still be added to a code-declared dataset). Platform-wide content (the classifier is one deployment-wide instance) → admin-gated, like Sigma/intel. Returns {kind, dataset, added?, records, created}; the read side is GET /v1/posture/dlp's estate
DELETE /v1/posture/dlp/datasets platform operator (canManageDLP) the DLP manage leg's erase path (9d-2) — the DPDP/GDPR right-to-erasure counterpart of the upload. Body {kind, dataset, values[]?, doc_id?, all?} — all:true drops the whole dataset; otherwise edm erases each record in values[] (digested server-side to the salted digests actually removed) and fingerprint erases the document named by doc_id. Each removal emits a durable it/platform/dlp-dataset removed event carrying ONLY salted digests (an add-then-remove nets to removed on boot-replay, so an erasure survives a restart), never a raw value. 404 when the named dataset does not exist. Returns {kind, dataset, removed?, records} or {kind, dataset, removed_dataset:true}
POST /v1/posture/dlp/datasets/edge platform operator (canManageDLP) the DLP dataset's edge-distribution opt-in (mote-dlp2): clears ONE protected dataset to ship to Sensor agents over the SIGNED DLP feed, so edge DLP matches the customer's EXACT records/documents, not merely generic PII shapes. Server-side is the DEFAULT (the EDM doctrine: the index is a server-side asset); this is the deliberate, audited exception. Body {kind:"edm"\|"fingerprint", dataset, edge:bool}. Durable (it/platform/dlp-dataset edge-enabled/edge-disabled, replay-restored). 404 when the named dataset does not exist (a phantom can't be distributed). Returns {kind, dataset, edge}; the read side is the edge flag on each GET /v1/posture/dlp estate dataset
GET /v1/posture/hardening/baselines read the shipped hardening-baseline catalog (harden.Summaries — CIS Linux L1, CIS Linux L2, DISA STIG) as operator-facing summaries: {id, name, profile, controls, benchmarks{CIS/STIG→count}, severities, auto_remediable, shipped}. id is the name to POST to evaluate/enforce. Feeds the Anvil board's baseline picker + tiles
GET /v1/posture/hardening/benchmarks read the UPSTREAM benchmark-content catalog + tenant currency join (2026-07-10): {catalog:[{id, title, source[DISA\|ComplianceAsCode], license, version[V2R8\|0.1.81], date, platforms[CPE], profiles[{id,title,selected}] — the ComplianceAsCode profiles carry the CIS-aligned L1/L2 / STIG / PCI-DSS / ANSSI baselines, rules(count), severities}], currency:{catalog_size, catalog_newest, by_source, assets:[{asset, os, matches:[{benchmark, title, source, version, date, matched_on[os\|service-name]}]}], unmatched[]}} — per CMDB asset, which PUBLISHED benchmark (at which release) applies via conservative CPE/version matching, and which assets have NO guidance at all (the coverage gap). ?id=<benchmark-id> returns ONE benchmark with its full rule list (the hardening points; 404 unknown). Currency mechanism: scripts/{check,update}-benchmark-catalog.sh (no upstream API exists — DISA next-release HEAD-probe + the ComplianceAsCode GitHub releases API); official CIS content lands under source CIS once the SecureSuite license is procured (2026-07-10 decision)
POST /v1/posture/hardening/evaluate act CIS/STIG evaluate trigger — submit the host facts FOUR ways: directly {asset, baseline?, state}; collector-fed {asset, baseline?, osquery:[…]} (the raw osquery scheduled-query result snapshot, mapped via harden.StateFromOsqueryFor using the collector pack for the chosen baseline — CIS Linux L1/L2 + DISA STIG → source:"collector"); collector-fed {asset, baseline?, inventory:{…}} (a generic host-inventory document — harden.HostInventory: sshd_config/services/login_defs/listening_ports/world_writable_files + the L2/STIG sections sysctls/kernel_modules/auditd/security — the way a config-management/CMDB agent emits, mapped via harden.StateFromInventory, baseline-agnostic → source:"inventory"); or natively-fed {asset, baseline?, audit_policy:[{subcategory, setting}]} — {"subcategory":"<canonical lowercase hyphenated unbraced subcategory GUID>","setting":"success\|failure\|success,failure\|none"} rows, mapped via harden.StateFromAuditPolicy to auditpol.<slug> State keys — the per-subcategory Windows audit policy that has NO osquery carrier at all. osquery and audit_policy are complementary HALVES of one host's observation and COMBINE (source becomes the composite "collector+auditpol"; either alone keeps its own single-source "collector"/"auditpol"); inventory and state are competing ANSWERS to the same question and stay EXCLUSIVE of the collector sources — mixing either with osquery/audit_policy is refused with 400. All four forms feed any baseline (L1·L2·STIG·windows-l1·windows-dc-l1). Runs the baseline, ingests the check events (→ posture + drift alerts), returns {checks, posture, source} (+ collected derived facts for the collector/native/inventory forms, state excepted — the caller already holds what it sent) + not_collected — the baseline params NO submitted source gathered, omitted entirely when nothing is missing — on ALL FOUR forms (the under-claim guard: an uncollected control reads non-compliant forever and looks exactly like a genuinely unhardened host). The measure step (no host change)
POST /v1/posture/hardening/enforce act CIS/STIG re-enforce trigger (the write step) — submit {asset, baseline?, state\|osquery\|inventory\|audit_policy, source_rule?, confidence?} (osquery = the osquery snapshot mapped via the baseline's collector pack, inventory = the generic host-inventory document, audit_policy = the native Windows audit-policy report — all mapped to the State server-side, all feeding any baseline, same alternatives and combining/exclusivity rules as /evaluate); runs the monitor pass then gates the whole re-enforcement through the conductor. Returns {mode, applied, deviations, blockers, posture, source} (+ collected for the collector/native/inventory forms) + not_collected on all four forms, the same under-claim guard /evaluate carries: a crown-jewel host / over-cap blast radius / engaged kill switch HOLDS or DENIES it (host untouched); only an auto-execute applies
POST /v1/vuln/agentless act agentless vulnerability assessment (VA P5, D-VA5) — the brain evaluates the loaded definition bundle (EVENTLAKE_VULNDEFS) against every CMDB host in the caller's tenant that no registered agent covers, using that host's reported software as the only readable fact. Findings ride the SAME it/vulnerability/finding/scan contract a Sensor sweep emits (so the vuln arm prioritises them with no new code); the tool attribute names the producer (Helix Agentless VA). Returns {targets, assessed, vulnerable, indeterminate, agent_covered, no_platform, no_inventory, defs_rev, hosts} — the three skip counts are separate BY DESIGN ("put an agent on it" / "teach the OS mapping" / "find out why the inventory is empty" are three different jobs). 409 when no definitions are loaded, never an empty 200: "0 vulnerable" over a fleet nothing was evaluated against is indistinguishable from a silent failure. Each swept host gets an asset.Assessment marked method:"agentless", because a pass that abstains on every predicate needing host access (patch state, registry, files, services, config) makes a materially weaker claim than an agent's. Scheduled form: EVENTLAKE_AGENTLESS_VA
GET /v1/detections/inventory read the LIVE detection-content inventory + measured coverage — every detection loaded RIGHT NOW (built-in DaC catalog + runtime Sigma + runtime correlations + Rules-pack-installed rules) → {total, by_source, by_engine, techniques, by_technique, runtime_only_techniques, detections[]}. Distinct from /v1/detections (the static corpus) and /v1/coverage (the framework declaration): this is what the platform is ACTUALLY detecting, operator-registered content included. runtime_only_techniques is the point — the techniques covered ONLY by content the operator registered or installed, i.e. the visible value of a one-click Rules pack. A pack-installed rule is RE-ATTRIBUTED to pack:<id> rather than double-counted, so by_source sums to total. TENANT-SCOPED (2026-07-22): correlation rules are the one tenant-bearing input, so a tenant sees the platform-wide rules plus its own and never another tenant's rule metadata — every figure here is tenant-relative, and a Platform super-admin's fleet view is a different number
GET /v1/assurance/report OPEN ⭐⭐ the Coverage & Evidence Report (the assurance plane, docs/02-analytics-plane.md#assurance-plane) — per taxonomy: {covered, total, pct, cited, uncited, cite_pct, gap, by_class, addressable, unclassified} plus the corpus totals {detections, test_refs, lint_errors}. cited is the artifact: the number of COVERED techniques carrying at least one detection that names the Go tests proving it — a coverage map anybody can draw, a citation either runs in CI or does not. uncited is published rather than netted away. as_of is the TAXONOMY CATALOG date, never the wall clock: a report stamped time.Now() re-dates itself on every read and so proves nothing about when it was measured. Counts only — carries no technique ids (guard-tested). Property of the BUILD, no tenant data (the /v1/coverage precedent)
GET /v1/assurance/standard OPEN the Evidence Standard — 7 questions any security product should be able to answer about itself, each naming the INSTRUMENT that produces its answer, with this build's answers filled in and an AnswerKind ∈ measured / asserted / unanswerable. ⛔ Q6 (a population false-positive rate) is unanswerable and stays in DELIBERATELY — a standard trimmed to our strengths is a scorecard drawn around our own shape. version tracks the QUESTION SET, never the answers, so a buyer can cite it while comparing vendors
GET /v1/assurance/domains OPEN the estate kinds the blindness assessment accepts (enterprise-it · mobile-fleet · ot-ics · ai-ml · mobile-core · web-apps). Served rather than hard-coded in the page so a client written before a domain existed cannot silently offer a stale list
POST /v1/assurance/blindness OPEN (rate-limited, own bucket) the Telemetry Blindness Assessment, AGGREGATE form. Body {domains:[...]} → {technique_count, covered, partial, blind, structural, addressable, unclassified, matrix_names{}, feeds[{feed, count, matrix}], singletons} (partial and matrix_names since 2026-10-12ty; the web-apps domain is measured against owasp-web-2025). blind and structural must be read together — the first is closeable and each row names its feed, the second no feed will ever close; publishing the first alone is the shape of every vendor's gap slide and it sells the purchase. ⛔⛔ THE DISCLOSURE IS THE DOOR, NEVER THE BODY — a {"disclosure":"detailed"} field is ignored, and TestAnonymousBlindnessIgnoresABodySuppliedDisclosure sends exactly that. ⚠ An EMPTY domain list yields an EMPTY assessment, never the whole taxonomy set: "I told you nothing about my estate" must not be read as "assess me against everything"
POST /v1/assurance/blindness/detail read the same assessment, DETAILED — adds the technique ids and the per-row feed clause behind each cluster, and each feed's ledger notes[] kept apart from its name (2026-10-12ty). ⛔⛔ A SEPARATE PATH ON PURPOSE. It is a per-technique map of what EVERY deployment of this platform cannot see; keeping it off /v1/assurance/blindness is what lets api/public.go admit the anonymous door by EXACT name and refuse this one, which a /v1/assurance/ prefix could not express — the identical trap /v1/reputation/, /v1/message/ and /v1/resolver/ each record, and sharper here because the credentialed sibling already exists
GET /v1/detections/backtest read detection efficacy (N8): replay the tenant's RETAINED events (window ?since/?until) through the deployed rule layer — a HERMETIC fresh engine (live state/dedup/lake untouched), the registered correlation rules included via the correlate replay harness (2026-07-10) — and score fired alerts AND correlation findings against ground truth → {events_replayed, alerts_fired, correlations_fired?, correlations_replayed?, unreplayed_correlations?, truth_positives/negatives, rules[{rule, fires, tp, fp, unlabeled, precision, recall, fp_rate, detected, correlation?}], overall}. Truth: ?truth=/?benign= comma-separated event-id labels (purple-team/Validation); absent → derived from the tenant's analyst dispositions (it/conductor/feedback ⋈ alert evidence). ?rule= narrows to one detection. Each run lands as a trendable it/backtest/report event. events_replayed==0 is the keep-the-mock signal
GET /v1/comply read compliance-as-code catalog (N8): every framework mapped as code — {frameworks[{id, name, version, controls, automatable}], count}. Ships nist-csf-2.0 (22 categories, 14 automatable) + the AI-governance pack (eu-ai-act · nist-ai-rmf-1.0 · iso-42001) + the PQC migration-clock pack (cnsa-2.0 10/4 · nist-pqc-transition 8/5 — SR13-P4)
GET /v1/comply/{id} read the audit-ready assessment: the framework's declarative evidence checks run against the caller tenant's lake window (?since/?until; absent = lifetime) → per-control {status ∈ satisfied/partial/no-evidence/manual, checks[{count, required, latest, sample[]}]} + per-function groups[] + summary{evidence_pct} (satisfied ÷ automatable — manual controls never inflate it). Each sample id is a sealed lake event the auditor retrieves verbatim (/v1/events?id=). Every run lands as a trendable it/comply/assessment snapshot. Unknown framework → 404
POST /v1/erasure/shred approve (admin) crypto-shred (N13): destroy the Data Principal's data-encryption key — the IRREVERSIBLE physical fulfilment of a recorded right-to-erasure. With EVENTLAKE_SHRED_DIR set, a subject's classified Confidential+ spans were stored as per-subject encrypted tokens (the seal covers the CIPHERTEXT, so the tamper-evident record survives untouched); destroying the key renders them permanently [erased] everywhere — disk, segments, lake backups. Fail-closed: 409 without an erasure obligation on record (POST /v1/erasure first), 501 on a deployment without a keystore. Body {subject, reason?} → {subject, key_id, key_destroyed} (key_destroyed:false = the subject never had encrypted data; the obligation still closes). Emits the sealed it/governance/crypto-shred proof event boot replay uses to re-destroy the key against a stale-backup keystore
POST /v1/governance/retention act set (or, with retain:"0", remove) the tenant's per-purpose retention policy (N13): {purpose, retain:"720h"}. The governance sweep clock enforces it — a subject whose basis for the purpose ended more than retain ago gets a FIELD-level erasure obligation scoped to exactly that grant's data categories, while their other purposes stay lawful. Event-sourced (it/governance/retention-policy, restart-safe)
GET /v1/governance/retention read list the tenant's per-purpose retention policies → {policies[{purpose, retain, tenant, set_at}]}
PUT /v1/monitoring/profile approve (admin, step-up) declare/update the tenant's lawful-monitoring profile (O4, GDPR Art. 88 worker monitoring): {purposes[{name, legal_basis, categories?, retention_seconds?}], pseudonymize_display, works_council_ref?, reveal_ttl_seconds?} — the works-council boundary as config; undeclared monitoring purposes are SKIPPED by the analytics scheduler and breach once per declaration (monitoring-violation → alert → case). reveal_ttl_seconds ≤ 28800 (consent.MaxRevealTTL, 8 h — one JIT elevation, the PAM bound) and both *_seconds fields are refused 400 when negative or over their bound, checked on the INT before the Duration multiply (2026-09-19bb); an over-ceiling profile replayed from before that is capped at approval
GET /v1/monitoring/profile read the tenant's declared profile (404 when none — no profile = unconstrained monitoring)
GET /v1/monitoring/notice read the generated employee-facing transparency notice (Art. 13 in the employment context): purposes/legal bases/retention/rights + a rendered Markdown text
POST /v1/monitoring/reveals act request de-pseudonymization of a display pseudonym: {pseudonym, case, justification} — must anchor to an EXISTING case in the tenant; the requester is the caller; stays pending until a second operator approves
POST /v1/monitoring/reveals/{id}/approve approve (admin, step-up) the four-eyes second operator: activates the reveal time-boxed (profile reveal_ttl, default 4h), scoped to the REQUESTER's reads; self-approval is refused server-side
POST /v1/monitoring/reveals/{id}/deny act refuse (or withdraw) a pending reveal: {reason?}
GET /v1/monitoring/reveals read the tenant's reveal audit queue (each record carries the pseudonym, never the identity)
GET /v1/searches read list the tenant's correlation searches (operator-defined saved-query detection rules)
POST /v1/searches analyst+admin register/upsert a correlation search (a saved query + threshold; tenant forced) — run by the analytics scheduler → it/alert/saved-query → case when the threshold is crossed. An optional group_by field turns it into a grouped/aggregation search (Splunk stats count by …): one alert PER group (field value) whose count ≥ threshold, e.g. group_by:"src_ip" → one alert per offending IP (per-offender throttled). window_seconds (0 = the whole lake) and throttle_seconds (0 = none, ≤ 86400 — the longest throttle the engine enforces, assembly.MaxSearchThrottle) are refused 400 when negative or out of range, on the count (§4.2 Duration fields, 2026-09-19dw). Survives a restart (durable via D22 replay).
DELETE /v1/searches/{name} analyst+admin remove a correlation search (same-tenant only)
POST /v1/searches/run act run the tenant's correlation searches NOW (bypassing throttle) — "test my searches"
GET /v1/adf · POST /v1/adf · DELETE /v1/adf/{id} read · manage-detections (correlation/search/pattern payloads) · platform operator (a sigma or yara payload) ADF units (unified-detection-framework §9 G2): the POST body is the RAW *.detection.yaml/JSON file — ONE document = the governance envelope + verbatim logic payload (sigma/native-correlation/search/yara live — yara via the capture plane, byte fixtures data_base64; pattern/compiled-arm-ref envelope-only) + behavioral tests: fixtures + tuning: exceptions + authored response:. Registration runs the full fail-closed gate (payload compile → DaC lint → one-id-space shadow check → fixtures executed in memory against the unit's own logic); the payload registers through the existing durable registries, the envelope joins GET /v1/detections with provenance operator, and one durable it/platform/adf-unit event makes it restart-safe. DELETE retracts the rule + its exceptions + the catalog record. Tenant-scoped on all three verbs (2026-07-22): a unit row carries source — the WHOLE authored document, including fixture events that hold sample telemetry — so GET returns the caller's own units plus platform-wide pack content (tenant: ""), never another tenant's; DELETE removes only what the caller's tenant authored, answering 404 (not 403) for anything else, so the refusal does not disclose who holds an id, and refusing pack content too, since honouring one tenant's delete would retract a live detection from every other tenant (pack content goes out with the pack); POST's documented same-id UPSERT is exempt from the one-id-space rule only for the tenant that authored it, so a second tenant cannot substitute its own logic under the first's detection id. A Platform super-admin keeps the fleet view and the unscoped delete
GET /v1/playbooks · POST /v1/playbooks · DELETE /v1/playbooks/{id} read · manage-detections SOAR playbooks (response-as-code, blueprint C14 — conduit/playbook): the POST body is the playbook JSON (trigger + a DAG of enrich/decide/act/notify/task steps). Registration runs the fail-closed Validate gate (non-empty trigger · acyclic · reachable · resolving references), the tenant is FORCED from the principal, and one durable it/platform/playbook-def event makes it restart-safe (replay re-validates). GET lists the tenant's + platform-wide sets; DELETE is tenant-scoped. Authorship is forced too (2026-07-23): registered_by is overwritten from the caller and a body-supplied value is never believed — a notify step is the one door whose CONTENT (subject/body) is author-written prose mailed under the tenant's verified sender identity, so the record names the person and notify_steps counts how many steps of the document can reach the wire. Both ride the serialized document, so the attribution survives replay. The prose itself is deliberately NOT filtered; what the platform bounds is the recipient (allow) and the message's declared machine origin (RFC 3834 Auto-Submitted: auto-generated + X-EventLake-Ref, sent alongside its retired twin X-HELIX-Ref for the alias window — internal/wirehdr owns the pairing — added by conduit/email to every message it composes)
GET /v1/playbooks/runs read the tenant's run snapshots, newest first: status (running/awaiting-approval/succeeded/compensated/failed), cursor, vars, per-step trace, the compensation stack, and the held conductor request id when parked
POST /v1/playbooks/runs/{id}/resume admin (approve) + step-up release a run parked on a HELD act step: the approval drives back through actions.Approve (a hold already released via /v1/actions/{id}/approve folds its RECORDED outcome — never re-executed) and interpretation continues. Same bar as approving the underlying action
GET /v1/sigma/rules · POST /v1/sigma/rules · DELETE /v1/sigma/rules/{id} read · manage-detections (POST, correlation documents) · platform operator (POST of a single-event rule, and every DELETE) the ONE Sigma door: POST the raw Sigma YAML (or {"source":"<yaml>"}) — a single-event rule compiles into the LIVE Sigma detector; a document stream carrying a correlation: meta-rule imports onto the NATIVE correlation engine (correlate.FromSigma, the Sigma v2 correlation extension). Durable via D22 replay. Returns {registered, kind: "rule"\|"correlation", ids}. The two branches scope differently: a correlation meta-rule is stamped with the CALLER's tenant (the correlation registry is keyed by (id, tenant), like POST /v1/correlations and ADF's correlation payload), while a single-event rule stays platform-wide — the Sigma detector registry has no tenant dimension. Tenant "" is reserved for signed Rules pack content: a "" rule runs over every tenant's events, shares one id-space, and can be deleted by nobody (no principal holds tenant "")
GET /v1/capture/rules · POST /v1/capture/rules · DELETE /v1/capture/rules/{name} read · platform operator (both mutations) the ONE YARA door (the platform's BYTES language over the body-capture plane): POST the raw YARA text (or {"source":"<yara>"}) — the source must compile to exactly ONE named rule (the registry key; re-registering upserts) and may not shadow a built-in library rule; it loads into the LIVE capture-plane scanner. GET lists the runtime-registered rules {count, rules[{name, technique, source}]} (the built-in compiled library is detecteng's territory, not listed); DELETE unloads by rule name. Platform-wide content (admin-gated), durable via D22 replay. The lightweight direct door alongside the heavier POST /v1/adf (kind: yara), which wraps a rule in a full authored unit with behavioral fixtures. Returns {registered: <name>}
GET /v1/correlations · POST /v1/correlations · DELETE /v1/correlations/{id} read · manage-detections native-correlation rules (conduit/correlate) — operator-authored multi-event/sequence content, tenant-forced, run by the analytics scheduler; management-plane only
POST /v1/spl/translate manage-detections incumbent displacement (W39) — the dry-run migration preview: a Splunk SPL search in, the emitted Sigma document + the NAMED translation notes out, registering NOTHING. Body = JSON {name, search, earliest?, level?, techniques?, index_map?, sourcetype_map?} (one search, strict — errors are 400s naming the refused construct) or a raw savedsearches.conf export (bulk, LENIENT — per-stanza results). Fail-closed: eval/rex/lookup/join/subsearches/macros/… are refused, never silently dropped; the counted stats count by … \| where count>=N shape emits a base rule + event_count correlation
POST /v1/spl/import manage-detections · platform operator for any search translating to a single-event rule translate AND register each search through the SAME one Sigma door (RegisterSigmaDocument — durable, governed, edge-lowerable), upserting by the derived spl-<slug> id on re-import within the caller's tenant; disabled stanzas are skipped, per-stanza failures carry their named reason. Returns {count, imported, results[{name, id, kind, ids, sigma, notes, skipped?, error?}]}
POST /v1/kql/translate manage-detections incumbent displacement (W39 part 2) — the Sentinel/Defender dry-run migration preview: a KQL query in, the emitted Sigma document + the NAMED translation notes out, registering NOTHING. Body = JSON {name, query, period?, level?, techniques?, table_map?, trigger_operator?, trigger_threshold?} (one query, strict — errors are 400s naming the refused construct) or a Sentinel ARM analytics-rule export (recognized by its resources array; bulk, LENIENT — per-rule results). Fail-closed: let/union/extend/join/mv-expand/dcount()/… are refused, never silently dropped; the counted summarize count() by … \| where count_>=N shape emits a base rule + event_count correlation, and a plain query's triggerOperator: GreaterThan+triggerThreshold becomes a GLOBAL event count over queryPeriod (a group-counting trigger over a summarize is refused — groups ≠ events)
POST /v1/kql/import manage-detections · platform operator for any query translating to a single-event rule translate AND register each query through the SAME one Sigma door, upserting by the derived kql-<slug> id on re-import within the caller's tenant; disabled rules and non-query kinds (Fusion, ML) are skipped, per-rule failures carry their named reason. Returns {count, imported, results[{name, id, kind, ids, sigma, notes, skipped?, error?}]}
POST /v1/esdsl/translate manage-detections incumbent displacement (W39 part 3) — the Elastic Security dry-run migration preview: an Elastic kuery or ES-DSL query in, the emitted Sigma document + the NAMED translation notes out, registering NOTHING. Body = JSON {name, query, language?, index?, index_map?, level?, from?, techniques?, filters?, threshold?} (language = kuery [default] or dsl; one rule, strict — errors are 400s naming the refused construct) or an Elastic "Export rules" NDJSON (recognized as multi-line / type-keyed; bulk, LENIENT — per-rule results). Fail-closed: lucene/eql/esql + query_string/regexp/nested/script/fuzzy/multi_match/… are refused, never silently dropped; a threshold rule emits a base rule + event_count correlation (from: now-6m = timespan; a cardinality distinct-count is refused), and a bare scoring-boost should is dropped with a note
POST /v1/esdsl/import manage-detections · platform operator for any rule translating to a single-event rule translate AND register each rule through the SAME one Sigma door, upserting by the derived es-<slug> id on re-import within the caller's tenant; disabled rules and non-query types (machine_learning, new_terms, threat_match) are skipped, per-rule failures carry their named reason (eql/esql/lucene). Returns {count, imported, results[{name, id, kind, ids, sigma, notes, skipped?, error?}]}
GET /v1/exceptions · POST /v1/exceptions · DELETE /v1/exceptions/{id} read · manage-detections tuning-as-code (§9 G4): the governed negative space — an Exception{id, rule, entities[], expires, reason, approver, severity?} (expiry + reason + approver REQUIRED; tenant FORCED from the principal) suppresses — or, with severity, DOWNGRADES — a matching admitted alert pre-sink; the finding events stay on the lake and every application is audited as it/platform/detection-exception. Durable via it/platform/exception (D22 replay); expired exceptions lapse lazily and stay listed for visibility
GET /v1/thresholds · POST /v1/thresholds · DELETE /v1/thresholds/{id} read · manage-detections tuning-as-code, the numeric half (§9 G4 follow-on, 2026-07-13): per-detection threshold overrides — a ThresholdOverride{id, rule, threshold≥1, expires?, reason, approver} (reason + approver REQUIRED; expiry OPTIONAL — a tuning setpoint may stand; tenant FORCED from the principal) retunes ONE detection's count gate per tenant, resolved INSIDE the counting engines (search rules · correlate batch+stream · the windowed threshold detectors) so raising AND lowering both hold; the governed alert/finding carries threshold_override: <id>. Durable via it/platform/threshold-override (D22 replay); tenant-specific beats platform-wide, expired overrides stay listed but never resolve
GET /v1/forwarders read continuous egress (review §7a W40): list the caller's downstream forwarders + delivery stats + the pipeline reduction ledger ({forwarder, stats{…, matched, reduced, bytes_raw, bytes_sent, reduced_by}, reduction{events_matched, events_reduced, events_sent, bytes_raw, bytes_sent, event_reduction_pct, byte_reduction_pct, reduced_by_op}}); secrets AND webhook header values REDACTED; a tenant also sees an all-tenant uplink (transparency — its data flows out), a Platform key sees the fleet
GET /v1/connectors[/{id}] read the one-click integration catalog (connector.Registry): every registered manifest + enabled state; a WASM-plugin-backed entry carries plugin{tier, digest, key_id, capabilities, size_bytes} — the Integrations-board provenance block
POST /v1/connectors/{id}/enable · …/disable manage-connectors toggle an integration (loads/unloads its default rules, routes its decoder on ingest); durable via it/platform/connector (D22)
POST /v1/connectors/plugins install-plugins (admin + Platform) the RUNTIME INSTALL surface for signed WASM connector plugins (workstream K; docs/08-interfaces-and-extensibility.md#plugin-authoring-guide §7): body {descriptor{…signed wire form}, wasm: base64, name?, summary?, docs?} — the server RECOMPUTES the module digest from the uploaded bytes (a post-signing swap breaks the signature), verifies the descriptor signature against the KMS keyring AND the deployment's capability policy AT THE DOOR (fail-closed — an unverifiable module is never persisted), then registers a DISABLED source connector. Module cap 4 MiB. Durable via it/platform/plugin (D22): replay re-verifies every descriptor against the CURRENT keyring, so a revoked signing key kills its plugins across restarts. Execution stays behind the plugin.Runtime seam — without the perf-tier wazero engine every decode refuses loudly, never silently unsandboxed
DELETE /v1/connectors/plugins/{id} install-plugins uninstall an installed plugin, enabled or not (built-ins 404 — they never leave through this door); durable removal, replay honors it
POST /v1/connectors/plugins/keys · DELETE …/keys/{id} install-plugins the plugin signing-key TRUST CEREMONY: import/rotate ({key_id, public_key: base64 ed25519, tier} — tier REQUIRED, and a descriptor signed by the key verifies only when it claims exactly that tier, at install and on every load; a pre-binding record replays unbound and may never sign first-party — B8-D6, 2026-09-16tb) or revoke a vendor key, durable via it/platform/plugin-key — seal-VERIFIED on replay (a keyring change is a trust decision, the source-key-grant precedent), and the door manages ONLY its own ids (it can never overwrite a platform/edge-source key). Revoking kills every plugin the key signed, immediately and on every future boot
POST /v1/forwarders manage-connectors register (or replace by id) a downstream push pipe — Forwarder{id, sink: webhook\|syslog, format: json\|cef\|ocsf, url+secret+headers{} \| addr+network+tls+ca_cert, filter{domains, categories, types, min_severity, platform_recorded}, pipeline[]Step, queue_cap, disabled}. platform_recorded (2026-10-12bd, B38) passes only records the platform itself filed, never one a client door submitted — the tenant's AUDIT STREAM is {categories:[audit], types:[admin, access], platform_recorded:true}; the forward package cannot judge provenance, so the flag FAILS CLOSED anywhere but the brain's tap. Pipeline = the Cribl-style egress wedge, two phases: REDUCE (drop·sample 1-in-N by event-id·suppress windowed dedup — run at the tap before enqueue, each takes a when scope + exempt_min_severity so alerts always pass) and SHAPE (mask·trim·project·set — run at drain on a clone; a shaped copy strips its integrity seal). Webhook headers carry the collector auth (Splunk HEC / Sentinel bearer), values redacted on read; reserved headers (Content-Type, X-Aether-*) refused. Fail-closed shape (named 400s), tenant FORCED from the principal (Tenant "" all-tenant uplink is Platform-only). The sink DESTINATION must be permitted by the tenant's allow_sinks policy (2026-07-23) or registration 400s naming the host and the remedy; the drain pump re-checks every cycle, so a policy tightened after registration — or a pipe replayed onto a node whose bound is narrower — stops delivering and lands ONE it/platform/egress-refused per transition (the queue HOLDS rather than drops, so a bound corrected within the queue's depth loses nothing) and the GET status carries a refused reason instead of a healthy-looking delivered:0. registered_by is FORCED from the caller (a body value is discarded, the created_by precedent) and rides the durable record alongside a queryable destination. The id space is (id, tenant): registering at an id another tenant — or the OEM's all-tenant uplink — already uses creates the CALLER's own pipe and touches neither; before this, an upsert by id silently replaced and thereby destroyed the other's egress. Durable via it/platform/forwarder (D22); delivery off-hot-path (drop-oldest on overflow, audited as it/platform/egress-drop); NOT metered
DELETE /v1/forwarders/{id} manage-connectors remove a forwarder (tenant-guarded: an all-tenant uplink is only the Platform key's to remove). Since ids are unique only WITHIN a tenant, a tenant principal always resolves to its OWN; the Platform key — the one principal that sees the whole fleet — names the owner with ?tenant= (_default = the all-tenant uplink) and gets a 409 rather than a guess when an id exists in more than one tenant
GET /v1/detectpack/forwarders read the first-party forwarder-template shelf (the Rules egress row): copy-me sink configs with the wedge pre-expressed — splunk-hec, sentinel-ocsf, arcsight-cef-syslog, mssp-alert-uplink, incumbent-bill-cut — that the operator fills in (endpoint + credential) and registers via POST above. Read-only by design: egress NEVER auto-registers from a template (a signed pack pointing tenant data at an arbitrary receiver would be an exfil primitive)
GET /v1/detectpack/packs · GET /v1/detectpack/catalog read the installed detection-content packs (id/name/version/author + sigma_rules/correlations/detections/adf_units counts) and the first-party CATALOG shelf (curated packs annotated installed). Shared content, so every tenant reads both — a tenant that cannot install still needs to see what protects it
POST /v1/detectpack/packs · POST /v1/detectpack/catalog/{id}/install · DELETE /v1/detectpack/packs/{id} manage-packs (admin + Platform) install a signed third-party pack / one-click a first-party catalog pack (the platform signs it, then the SAME verify-then-load gate runs) / uninstall one, unloading every rule it owns. Install VERIFIES the KMS signature against the trusted keyring before any rule loads (unsigned/tampered/untrusted → 400, nothing enters the engine) and is ATOMIC. Why the platform gate and not manage-detections (2026-07-22): a pack's rules register at tenant "" — shared estate-wide, visible to every tenant and retractable by NONE of them — so a tenant admin uninstalling one would unload a live detection out from under every other tenant, through the door the unit door (DELETE /v1/adf/{id}) refuses them. Install and uninstall move together: content a tenant admin may not retract is content they must not be able to load. Re-installing an id is an UPSERT — the install retracts whatever the previous version owned and the new one drops, so an upgrade cannot strand un-retractable rules
GET /v1/itsm/bridges read two-way ITSM bridge (review §7a W17 residue, conduit/itsm + assembly/itsm.go): list the caller's registered ticketing-desk bridges + delivery stats ({bridge, stats{enqueued, delivered, dropped, failures, queue, last_error?, last_delivery?}}); secrets REDACTED; a tenant also sees an all-tenant MSSP bridge (transparency — its cases flow out), a Platform key sees the fleet
POST /v1/itsm/bridges manage-connectors register (or replace by id) a two-way desk pipe — Bridge{id, system: servicenow\|jira\|generic, endpoint, user?+secret?, table?, project?, issue_type?, min_severity?, status_map{external→triage\|investigating\|contained\|resolved\|closed}, state_out{case-status→ticket-state}?, queue_cap?, disabled?}. Outbound: case.opened → ticket create (SN Table API correlation_id = the case id / a Jira eventlake-case-<id> label, dual-written with the retired aether-case-<id> for the alias window — the at-least-once dedupe handle; generic = the platform's HMAC-signed webhook contract), case.status → work note + the operator-mapped ticket state. status_map is the INBOUND write-back policy, deny-by-default (empty map = comment-only bridge). Fail-closed shape (named 400s), tenant FORCED from the principal (Tenant "" all-tenant MSSP form is Platform-only). Durable via it/platform/itsm-bridge (D22); delivery off-hot-path (EVENTLAKE_ITSM_TICK, drop-oldest bounded queue, front-requeue retry — at-least-once); NOT metered
DELETE /v1/itsm/bridges/{id} manage-connectors remove a bridge (tenant-guarded: the all-tenant MSSP pipe is only the Platform key's to remove). Queued jobs discard; recorded case↔ticket links KEEP (history the drawer still renders)
GET /v1/itsm/links read list the caller's case↔ticket links ({bridge_id, case_id, tenant, ref{system, id, key?, url?}}; ?case_id= narrows to one case — the alert/case drawer's "external ticket" chip read). Rebuilt from durable it/platform/itsm-link events on D22 replay
POST /v1/itsm/callback ingest the desk's status write-back door — {bridge_id, ticket_id (opaque id OR human key — both indexed), status?, note?, actor?, tenant? (Platform keys only)} → {case_id, commented, transitioned, case_status}. The desk gets a least-privilege INGESTOR key (the radius-bridge precedent), tenant-forced; what a callback may DO to a case is bounded SERVER-SIDE by the bridge's operator-authored status_map (an unmapped status only ever comments) and the ticket→case link (an unlinked ticket refuses; a cross-tenant caller gets the same unknown-bridge refusal — no probing). Idempotent on status: a mapped transition to the case's current status folds nothing — the desk↔case echo-cycle terminator
GET /v1/fedsources read federated search / query-in-place (review §7a W43, conduit/fedsearch + assembly/fedsearch.go): list the caller's registered EXTERNAL stores (credentials REDACTED); a tenant also sees a platform-wide source (transparency — its federated reads can touch it), a Platform key sees the fleet
POST /v1/fedsources manage-connectors register (or replace by id) an external store — Source{id, kind: elastic\|splunk\|archive\|s3, url, auth{bearer \| user+pass \| header+value \| access_key+secret_key(+session_token)}, ca_cert?, region?, prefix?, mapping{time_field, message_field, host_field, user_field, source_ip_field, severity_field, category, type}, timeout_ms, max_results, disabled, retain_original?}; fail-closed shape (named 400s), tenant FORCED from the principal (Tenant "" platform-wide source is Platform-only). retain_original stamps each mapped hit's Original with the store's VERBATIM document bytes (federated reads return them; a backfill lands them — a job can also opt in per-job without touching the source). Kind s3 = a PRIVATE S3-compatible bucket (AWS/MinIO/Ceph/R2) reached directly under clean-room AWS SigV4 signing — url is the bucket base, prefix scopes keys, region overrides the signing region (else derived from an amazonaws.com host / us-east-1), and the SigV4 pair is REQUIRED for (and exclusive to) this kind; list responses redact secret_key/session_token (the access-key ID stays visible, an identifier). Durable via it/platform/fed-source (D22); NOT metered (query-in-place ingests nothing)
DELETE /v1/fedsources/{id} manage-connectors remove a source (tenant-guarded: a platform-wide source is only the Platform key's to remove)
GET /v1/search/federated read ONE query scattered across the local lake + the caller's federated sources, merged time-ordered and returned with the per-source coverage ledger: the /v1/events query params plus sources=<id,csv> (default every enabled visible source; an unknown/disabled named source is a DECLARED failed row, never a silent skip) and include_local=false (external stores only). Response {count, events, sources[{id, kind, ok, hits, elapsed_ms, truncated?, error?}], partial} — a dark store never fails the union and never fails silently (§7b). External hits are mapped to canonical events (the raw document rides attributes + federated_source), post-filtered through the SAME store.Query predicates as local rows, then governed EXACTLY as ViewPage (a VIEW): the SR14-P5 need-to-know compartment gate (a compartmented LOCAL row is withheld from a viewer without the grant — external hits carry no EventLake label), tier masking by the caller's clearance, and Art. 88 pseudonymized display; the export-only erasure/correction overlays stay off. Audited as one fedsearch access audit that records the withheld reach as candidates (present only when it exceeds delivered). NO cursor: heterogeneous stores share no stable resume order — page via limit + the time window. Nothing is ingested. Native pass-through: spl=<SPL string> runs the splunk legs, dsl=<ES query clause, one JSON object> the elastic legs (both may be set — each source runs its own dialect); an SPL string must PROVE read-only against fedsearch.CheckSPL's command allowlist (quotes opaque, subsearches descended, macros/backticks refused — the registered upstream credential may hold write authority, and this door stays read because the gate keeps it a read) or the request is a NAMED 400; the DSL clause embeds inside OUR _search envelope (size/sort/range stay the platform's). On a native run the lake leg and any source the dialect routing cannot serve (archive/s3, or no query in that source's dialect) come back as DECLARED refused rows; the canonical params still post-filter every hit locally (an inline SPL earliest= can widen the upstream fetch, never the answer)
GET /v1/backfill read incumbent displacement — archive backfill (review §7a W39 legs 4+5, conduit/migrate + assembly/migrate.go; ADR docs/00-charter-and-strategy.md#incumbent-displacement): list the caller's backfill jobs (source credentials REDACTED; a Platform key sees the fleet) — each Job{id, tenant, source, since, until, chunk, retain_original?, auto?, paused?, slices_per_run?, failures?, status ∈ pending\|running\|completed\|failed, cursor, landed, bytes, slices, dense_skips?, error?, started_at, completed_at}. bytes is the DECLARED landed volume (the auditable ingest receipt — meter bills live ingress by event-time window, so history never distorts a current bill): the TRUE upstream byte sum with retain_original, else the canonical-JSON approximation; dense_skips counts declared force-advances past timestamp-dense instants (never silent loss); failures is the runner's consecutive-failed-pull count (reset on success/resume)
POST /v1/backfill manage-connectors create a backfill job: {id, source: {…inline fedsearch.Source} \| {id: "<registered fed-source>"}, since, until, chunk?, auto?, paused?, slices_per_run?, retain_original?} — pulls ride the EXISTING fedsearch drivers (one registered source serves W43 query-in-place AND backfill). Tenant FORCED from the principal; fail-closed shape (named 400s: bounded window required, cross-tenant/disabled source refused, slices_per_run bounded 0..1000); duplicate id refused. auto opts the job into the platform's backfill runner (EVENTLAKE_BACKFILL_TICK); retain_original lands each event with the VERBATIM upstream bytes as its Original (the drivers capture them at the decode seam — meter/forensics read the record itself and bytes becomes the exact upstream sum; off = canonical-JSON approximation, declared). Durable via it/platform/backfill-job full snapshots (D22 — a killed run resumes from the cursor)
POST /v1/backfill/{id}/run manage-connectors drive the job under the request context (?slices=N bounds the run; 0 = to completion). Each slice: pull → the SAME redact→shred governance as live ingest → the lake's Backfill door (sealed, WORM, persisted — NO subscriber dispatch: history is evidence, not signal; the SOC is never paged for years-old records, baselines never learn from them) → durable cursor snapshot. A pull failure reads 502 with the persisted job — re-running resumes; a foreign-tenant probe reads 404; a job already being driven (the runner, another operator) reads 409 — one driver per job, never a double-pull
POST /v1/backfill/{id}/pause manage-connectors park the job for the AUTOMATION (the runner skips it; a manual run still works). Idempotent, durable. The runner also pauses a job ITSELF after 5 consecutive failed pulls, emitting it/migrate/backfill-stalled (Medium) → the one migrate-backfill-stalled page (detect.BackfillStalledRule) — a parked migration is never silent
POST /v1/backfill/{id}/resume manage-connectors clear the pause AND the consecutive-failure count — the operator's way out of a stalled page after fixing the source (the runner gets its full failure budget back)
POST /v1/backfill/{id}/retroscan act replay the landed window through the deployed rule layer (HERMETIC backtest — live state untouched) → the per-rule efficacy report (also trended as it/backtest/report). CRITICAL fires over the history ⇒ ONE data-minimized it/migrate/retro-findings event → the single migrate-retro-findings page (detect.MigrateRule) — the "your history holds a breach" compromise-assessment moment; the per-fire detail stays in the report
GET /v1/detections/dualrun read dual-run coexistence scoring: replay ?since/?until through the deployed rule layer with evidence tracking and judge every IMPORTED rule (?rules=<id,csv>; default = the runtime Sigma registry — where the spl-/kql-/es- translator output lands — PLUS the registered correlation searches, replayed by the correlate harness) against the NATIVE detectors by cited-evidence overlap → {imported[{rule, fires, verdict ∈ silent\|unique\|partial\|redundant, overlap, counterparts[], tp, fp, precision, correlation?}], unscored[], native_fired[], parity, unique, silent, events_replayed}. Parity (redundant ÷ fired imports) rising toward 1.0 is the measured "safe to turn the incumbent off" evidence, trended as it/migrate/dual-run; a correlation verdict is marked correlation: true, and only a correlation the harness cannot replay (no bounded window) is DECLARED unscored, never silently dropped
GET /v1/rollouts read fleet update safety (review §7a.7 O1, conduit/rollout + assembly/rollout.go): list the caller's ring-staged rollouts with their LIVE verdicts — each {rollout{artifact{kind: agent\|content, name, version, digest}, prev_version, rings[{name, percent, agents[], min_bake_s, gate{min_reports, max_fail_pct, max_silent_pct}}], ring, state ∈ baking\|halted\|rolled-back\|complete, halt_reason}, verdict{advance, halt, reason, health{members, adopted, failed, silent}}, pending_action?} (a promotion parked AwaitApproval names its conductor request id). Health is folded NOW from sensor heartbeats ⋈ update acks
POST /v1/rollouts approve + step-up create a ring-staged rollout of an agent build / content bundle. Body = a rollout.Rollout (rings omitted ⇒ the doctrine plan canary 1%/1h → early 10%/4h → broad 50%/12h → fleet 100%/24h, zero-tolerance gates); validated FAIL-CLOSED server-side (sha256 digest REQUIRED — an unverifiable artifact never ships; strictly-widening rings ending at 100; min_reports ≥ 1 so silence never promotes), tenant FORCED from the principal. PER-PLATFORM RELEASES (D-AV4): artifact.digests{<triple>: sha256} replaces artifact.digest for a release that ships different bytes per platform — EITHER form, never both (the pair is a downgrade path: strip the set, serve the single, and both publisher signatures are genuine). The publisher signs the WHOLE SET plus the version (eventlake-releasesign sign-set), the brain relays it unfiltered, and the AGENT selects its own triple — a brain that pre-selected would be trusted, and serving the wrong platform's genuinely-signed build is otherwise undetectable. An agent whose triple the release does not ship, or which reports no triple at all, is NOT TARGETED (no directive), because directing it would put it in a ring and count it against the health gate for a build it can never apply. CHANNELS (D-AV6): channel ∈ dev|edge|beta|stable|lts scopes eligibility; empty = stable on both sides, so an agent that declares no channel is STABLE rather than "any lane". PLATFORM-SCOPED RINGS (D-AV7): rings[].platforms[] matches an agent's GOOS, its full triple, or a triple prefix at a component boundary; the LAST ring may not be scoped (complete would then mean "finished, having never reached them"), and an agent with no reported platform matches no scoped ring. Every transition is durable (it/platform/rollout full-state snapshots, D22 replay). The evaluator runs every analytics round AND on every ack: gate breach ⇒ AUTO-HALT (SevHigh audit; every poller reverts to prev_version within one poll); gate cleared ⇒ promotion PROPOSED through the conductor chain (promote-rollout, BlastRadius = the next ring's true measured size — a canary hop auto-executes, a fleet-wide hop is HELD)
POST /v1/rollouts/import approve + step-up the air-gap path: import a SIGNED offline bundle. Fail-closed against the trusted keyring — unsigned / unknown-key / tampered / invalid-plan bundles are all refused with named errors; the importing tenant owns the imported plan. The payload travels on its own medium and must hash to the manifest's digest (the bundle carries trust, not bytes)
POST /v1/rollouts/{id}/promote approve + step-up manually release the next ring — the human decision IS the gate (also how an operator releases a promotion the conductor parked). Audited with the operator's identity
POST /v1/rollouts/{id}/halt approve + step-up stop distribution NOW ({reason?}): state → halted, every agent poll serves prev_version from this moment — fleet-wide stop within one poll interval. Halting needs no conductor gate (stopping is always safe)
POST /v1/rollouts/{id}/rollback approve + step-up the terminal operator decision ({reason?}): stay on the previous version (valid from baking or halted)
GET /v1/rollouts/{id}/bundle approve export the platform-signed (ed25519, platform KMS key) rollout manifest bundle an air-gapped site imports offline
GET /v1/sensor/update?agent_id= agent (canAgent) the agent leg of O1: an agent polls the version directives that apply to IT — ring membership resolved SERVER-side (deterministic FNV bucket + pins), tenant forced from the agent key → {count, directives[{rollout_id, kind, name, version, digest?, state}]}. The agent re-verifies any fetched payload against the directive's sha256 digest before applying (the sensor re-derive discipline); a halted/rolled-back rollout serves prev_version to every poller
POST /v1/sensor/update/ack agent (canAgent) the agent's apply result: {agent_id, rollout_id, version, ok, detail?} — durable (it/platform/rollout-ack, D22) and evaluated IMMEDIATELY: a failed canary apply halts the rollout in this same call, not a round later. Tenant forced, so an agent can never ack (or halt) another tenant's rollout
POST /v1/sensor/register agent (canAgent) enrolls an agent ({agent_id, owns?, planes?, plane?, version?, channel?, triple?, platform?{os,arch,variant,os_flavor,os_version,kernel,libc,init}, unavailable?[{cap,reason}], wire?, feed_schemas?{feed:N}}; tenant forced) → {registered, feed_key_id, feed_key, wire_min, wire_max, tenant}. tenant (2026-09-23t, G5) is the tenant the agent was registered INTO, forced from its credential and always present ("" on a single-tenant node); the agent compares every command's SIGNED tenant against it, because an agent id is unique only per tenant and one platform key signs for all of them — an agent whose brain omits it refuses commands unless -accept-unsigned-commands waives that absence. Since 2026-09-23x the same declaration addresses every per-host/per-tenant content feed too: GET /v1/sensor/{segment,collect,vulndefs,dlp,harden,harden/lease} each carry a signed tenant (omitted when "", so a single-tenant node's signed bytes are unchanged), and the agent refuses one signed for another tenant (wrong-host on the feed ledger) — measured before the fix, a genuine t1/ws-9 artefact was adopted by t2/ws-9 on all six. ⚠ Rollout: on a HOSTED cell an agent older than 23x cannot verify these six feeds from a 23x brain (its re-marshal drops the field) and keeps its last verified content until upgraded. WIRE NEGOTIATION (phase 3): an agent BELOW wire_min is refused 409 with a reason naming both numbers, is still RECORDED on the fleet board (it is the upgrade worklist) but owns no targets, and is refused on every later pull too — negotiating once and then serving anything makes the negotiation decorative. An agent ABOVE wire_max is NOT refused: it degrades to wire_max and logs each feature it switched off by name. An ABSENT wire is read as 1, never as unknown — the un-versioned shape IS wire 1, and reading it otherwise would orphan every pre-versioning agent. The identity fields are additive and stay optional — the brain is always ≥ the agent, so a build that predates them registers unchanged. channel ∈ dev|edge|beta|stable|lts; an UNRECOGNISED value is dropped (the row then reads as not-reported) rather than refused, because bricking a newer agent on an older brain is the one direction the compatibility doctrine protects hardest AND is the hunt-feed TRUST CEREMONY: the response carries {registered, feed_key_id, feed_key} — the platform ed25519 verify key (base64) the agent PINS so every later GET /v1/sensor/hunts must verify against it
GET /v1/sensor/commands?agent_id= agent (canAgent) the agent's command drain → {commands[]}, tenant-namespaced so a cross-tenant agent_id sees an empty queue and can never observe (or drain) another tenant's pending containment. This poll IS the agent heartbeat (it stamps last_seen), so the SR2 island-mode spool depth rides it as optional counters — &spool_records=&spool_bytes=&spool_dropped= — costing zero extra round trips across the fleet. Best-effort and fail-closed: a malformed or negative counter is DISCARDED WHOLE (never half-applied — a partial figure on the fleet row is a lie about the edge's backlog) and never fails the poll, because a bad report must not cost the agent its commands. An optional &spool_legs=<leg>:<records>:<bytes>:<dropped>,… attributes that same total to the collector legs behind it; it degrades INDEPENDENTLY of the totals — a breakdown that is malformed, carries an unsafe leg name, duplicates a leg, exceeds 32 legs, or OVER-CLAIMS the totals is dropped whole while the totals still land (the totals are the operator's answer, the attribution only its footnote)
GET /v1/sensor/agents read (canRead, NOT canAgent) the OPERATOR fleet read, tenant-forced: {enrolled, online, stale, agents[{id, owns[], plane?, planes[]?, caps[]?, version?, channel?, triple?, platform{…}, unavailable[]?, wire?, wire_state?, wire_reason?, last_seen, state ∈ online\|offline, queued, spool?{records,bytes,dropped}, spool_legs?[{leg,records,bytes,dropped}]}]} — "is enforcement actually reachable across the estate?". 503 with Sensor actuation off. spool is absent when the agent never reported one (spooling off, no stream-backed leg, or a pre-SR2 build): that is unknown, NOT zero, and must not render as 0. It is accurate as of that row's last_seen, so for an offline agent it is a FLOOR — the edge has been spooling unobserved since, and the true backlog is almost certainly larger. spool_legs attributes that depth to its SOURCE legs (sorted by leg, every armed leg listed — a healthy one as an explicit zero, since ruling it out requires saying so), because the legs fail independently: one wedged tail backlogging while its siblings drain is indistinguishable from a host-wide outage in the sum alone. It is OPTIONAL beside spool and may be absent when the depth is known (a totals-only agent, or a breakdown the brain refused as inconsistent) — an empty spool_legs never means the legs are idle. A leg at records:0 with a nonzero dropped is the most alarming row, not the calmest: it is empty because its budget already discarded records. channel/triple/platform are the D-AV5 release+platform identity. An ABSENT channel means the agent build predates the field and has made NO claim — it must never render as dev; platform is {} for such an agent (omitempty is a no-op on a struct, so the empty object is the honest "said nothing"). triple is what the binary was BUILT for (rollout addressing); platform is the host it RUNS on (the support-matrix reality check) — the two answer different questions and a board that merges them loses both. unavailable is what that host CANNOT do and why, from the AGENT's own compiled support catalog (mote.UnavailableFor) — never derived brain-side, since only the build knows what the build can do. It is NOT redundant with caps: an absent cap conflates "not enabled", "this binary cannot do it here" and "the mechanism does not exist on this OS", and only the third belongs in a won't-fix column. An empty list is a real answer (Linux is the reference platform and reports none), not a missing one. Unlike channel the brain does NOT narrow it to ids it knows — a newer agent legitimately names a capability this brain has not heard of — but it is bounded (64 entries, 500-char reasons, id charset) because a fleet board is not a place an agent key writes unbounded text. wire_state ∈ ok|below-floor|degraded is DERIVED at read time from the brain's CURRENT range, never stored: a stored verdict would keep reporting the range that was current when the agent last registered, so a board could show ok for an agent the same brain would now refuse. A below-floor row is the upgrade worklist and carries wire_reason
GET /v1/sensor/profiles read (canRead), platform-scopeable (?tenant=) the capability-profile reconciliation (spec 2026-08-30-mote-capability-profile-design.md phase 0) — which agent legs the tenant's LICENCE should arm, diffed against the caps each agent self-declares: {agents[{agent_id, tenant?, legs[{leg, title?, state, reason?}], agreed, pending, impossible, unentitled}], summary{agents, agreed, pending, impossible, unentitled}, legs[{leg, title, valued, can_be_impossible}]}. 503 with Sensor actuation off, scope resolved first. ⭐⭐ IT IS A FOOTPRINT READ, NOT AN ENFORCEMENT ONE. Nothing here arms, disarms or refuses anything; no agent reads it. Licence enforcement stays at the brain, which refuses to route/store/analyse what was not bought — entitlement/licence.go records why edge-side enforcement on customer hardware is evidence, not prevention. What this door buys is that a leg nobody bought is not spending its share of the agent's per-host RAM ceiling across a fleet (the figure is footprintDefaultLimit, an owner-callable default — deliberately NOT restated here, because the two prior restatements of it in buyer-facing prose both drifted). ⛔ unentitled (armed, unlicensed) is a CONFIGURATION finding — never a licence violation, never a security event. It sorts first because it is the only state with a cost attached to leaving it alone, not because it is the most severe. ⛔ impossible must never collapse into pending: it means the AGENT ITSELF declared the capability unavailable on its OS (from unavailable[], carrying that agent's own reason verbatim — the host is the authority on what it cannot do), and on a leg needing a kernel program installed, pending's "any minute now" is false forever. ⚠ Measured 2026-08-30: only ONE leg (container-escape) can currently reach impossible — can_be_impossible on the served vocabulary says which legs ever can, and cannot be derived from the delta. ⚠ pending cannot separate "unconfigured" from "rollout in flight" and does not guess: there is no tenant configuration until phase 2 and no agent-side apply until phase 1. The leg vocabulary is served WITH the data so a board renders an added state as an unknown row rather than as nothing. ⭐ Plural and canRead deliberately — the singular GET /v1/sensor/profile is reserved for the phase-1 AGENT pull under canAgent, so neither route ever has to move.
POST /v1/sensor/profiles/legs/{leg} write (canManageConnectors — TENANT admin), Platform may name a tenant in the body the capability-profile tenant dial (spec phase 2): a customer administrator turning one licensed agent leg off for their own fleet. {arm, tenant?} → {tenant, leg, title, armed, disarmed[{leg, actor, when}]}. ⛔⛔ IT CAN ONLY EVER SUBTRACT. The licence resolves the CEILING and this dial removes from it; arming a leg whose licensing feature the tenant does not hold is refused 403 license_feature_absent — the SAME code licensegate.go already emits, so the portal's one shared refusal component renders it unchanged (a second code for the same fact is how a board grows two treatments for one remedy). ⚠ DISARMING IS UNCONDITIONAL, including for a leg the licence does not cover — the decision is durable, so a tenant whose licence LATER gains a leg they had already turned off must find it still off; refusing the write would mean a leg arming itself across a fleet because a purchase order was signed. ⚠ An impossible leg is not refused either: whether a leg can run is a per-HOST fact and this dial is per-TENANT, so a mixed fleet may have hosts that can and hosts that cannot. arm is REQUIRED — an absent field is 400, never a silent false, because disarming is the direction that makes telemetry go quiet. The leg is an EXACT token: the valued self-report form (dlp-egress:block) is 404, not a synonym. The tenant is FORCED from the key for a non-Platform caller, so a body naming somebody else's tenant is ignored. Durable — recorded as an it/platform/edge-profile-leg event (mote-profile-leg before 2026-10-26q, folded forever) and folded on replay, so a restart never re-arms what a customer turned off; ⛔ the fold reads the class ACTIVITY, never the writable armed attribute. An EMPTY configuration means EVERYTHING LICENSED, never nothing — only the OFF set is stored, so a lost fold costs memory rather than sight.
GET /v1/sensor/agents/{id} read (canRead), platform-scopeable (?tenant=) the per-agent drawer behind one fleet row — {agent{…the SAME AgentView the list serves…}, actuation{targets[], total, by_status{}, by_action{}, by_actuator{}, recent[{time, request_id?, action, target, status, actuator?, reason?, case_id?}], truncated}, edge_detections[], since?, until?} plus the scope envelope. Window via ?since&until (absent = lifetime, and an unbounded read carries NO window keys — a zero time.Time marshals as year 1). It exists because the list already carries every field the dispatcher holds, so the drawer earns its place only by the JOIN: a command is drained from the queue the moment the agent polls it, and the outcome lives on the LAKE as an it/conductor/action event. ⚠ The join is by owned TARGET, which is NOT proof this agent did anything — the shipped NoopActuator records executed without touching the host, so by_actuator is served BESIDE the status counts and a reader who wants "what this edge enforced" must read it. ⚠ Ownership is CURRENT, history is not, which is why targets (the join key) is in the payload. ⛔ The owned set is read verbatim, never defaulted to host:<id>: an agent refused below the wire floor owns nothing, and inventing a history for it would be wrong on the one screen an operator opens to ask why it is not enforcing. ⭐ {id} alone is not a key — ids are hostnames, unique only WITHIN a tenant — so an estate-wide (?tenant=*) lookup that matches two customers answers 409 naming the tenants to re-ask with, rather than silently picking one. 404 says the same thing whether the agent is absent or out of scope (the difference is an enumeration oracle), and carries the scope envelope so a TRUNCATED * search is visible: not-found over a partial search is a weaker claim than over the estate. Scope is resolved BEFORE the actuation check, so a refused caller never learns whether this deployment runs the Sensor (503).
GET /v1/change read (canRead), platform-scopeable (?tenant=) the observed-change log (Datum W17+W18) — {log:{since?, until?, total, by_kind{}, kinds[], changes[{at, kind, summary, target, event_id, identity?, source?, approval, basis_id?}], truncated, approval{state, sentence, records, read[], licensed, outside, unmatched, note}, drift{state, needs[]}}} plus the scope envelope; ?since&until (absent = lifetime), ?limit= bounds the RENDERED rows only. ⭐ The classification is conduit/diagnose's carrier table through ClassifyChange — the SAME table the root-cause plane's History section uses. Re-implementing it would let this board report N changes today while a diagnosis over the same window answered no-antecedent, with nothing to say which was wrong. ⛔⛔ THE THREE APPROVAL COUNTS DO NOT MERGE, AND THE BOARD'S "UNAUTHORIZED" TILE CANNOT BE COMPUTED HONESTLY: an observed change names its resource the way the PROVIDER does (arn:aws:ec2:…:security-group/sg-web), a change record names it the way a human filed it (sg-web), and conduit/legitimacy matches a resource by exact match plus one spelled container widening — no wildcards, no bare prefixes, an unstated field SILENT and never any. So licensed (a record covers identity and resource and window) and outside (the actor's OWN record excludes this act) are defensible, while unmatched conflates nobody filed a change with no record names this resource the way the provider does — the payload carries that caveat in approval.note so a board cannot drop it. ⛔ outside requires the IDENTITY axis COVERED: a record about somebody else names an identity that is not yours, so reading arbiter's bare Coverage.Outside() would flag every unrelated change the moment one record existed anywhere. ⛔ With NO change-record source the plane answers unreadable ONCE and no row is judged at all — arbiter's own refusal, and its sentence is rendered verbatim rather than re-worded here. ⛔ drift is DECLARED unbuilt with both missing feeds named (a declared-state source; a cloud current-state snapshot) — conduit/iacsec is NOT this capability, it is the IaC policy-GATE arm and holds neither repo nor cloud state. ?tenant=* FANS OUT one log per tenant (a merged change count is nobody's); an unscoped platform read gets its OWN tenant, not the estate.
GET /v1/facility read (canRead), platform-scopeable (?tenant=) the facility timeline (Bedrock W20+W21 panel 3) — {since?, until?, total, by_plane{}, events[{id, time, plane, activity, device, device_kind?, zone?, site?, severity, summary}], truncated, devices{observed, by_plane{}, note}, carriers[{plane, class, reads, not_carried[]}], correlation{state, zoned[], unzoned[], needs[]}, time_integrity{state: reporting|no-reporters, reporting, by_state{}, hosts[{host, state, severity, since, offset_ms?, validators[]?, reasons[]?, attribution: bound-device|unbound-name, agent: online|offline|unknown, last_seen?}], truncated, refused, reads, not_carried[], needs[]}, firmware{state, needs[]}}; ?since&until (absent = lifetime), ?limit= bounds the RENDERED rows only (total and every device count are computed over the whole window). Three planes on one clock: building-automation (state-changing BACnet services + the error/reject/abort responses to them — a read-property POLL is filtered out, the no-flood doctrine applied at the read) · access (card reads + the PACS's own door alarms) · camera (it/visionsec/camera-integrity). ⭐⭐ EVERY PLANE PUBLISHES WHAT IT DOES NOT CARRY (carriers[].not_carried), because two of the three carry less than the board draws: decode/bacnet decodes to the SERVICE level by design, so a write is carried and the property VALUE it set (a setpoint) is not; the camera finding carries statuses, not an outage DURATION. ⛔ The kind is camera-integrity (PH-T005 sensor impairment), NOT feed-integrity (PH-T006, a verdict about frame CONTENT on a camera that is still up) — neighbours in one package and not the same signal. ⛔ The cardholder never leaves this door for ANY caller: decode/pacs masks them into the Subject as the login-correlation join key, and a row's device is the DOOR — the identity-bearing join is H3's, under its own gate. ⛔⛔ The board's headline co-occurrence is DECLARED, never computed (correlation.state = unavailable): it is a join on a PLACE, and while the access and camera planes carry a zone from their own telemetry, a BACnet controller is identified by its IP and carries none — the payload names which leg is unzoned rather than joining the two that agree. ⛔ Out-of-hours is not computed either, and the reason is a TIMEZONE not a threshold (the only business-hours declaration here is visionsec's own gate, read against the event's UTC clock). ⛔ firmware is DECLARED unbuilt with the feed it needs — nothing reads a PCR (decode/attest is SOFTWARE supply-chain attestation, a name collision). ⭐ time_integrity FOLDS the Sensor's -clock-watch verdicts (2026-09-21ag) — the newest it/health/time-<state> per host (Subject host) up to until, worst-first on timesync's own ladder; ⛔ since does NOT bound it (the leg announces on TRANSITION, so a host synced for a week announced it a week ago); ⛔⛔ a verdict naming a host BOUND to an enrolled device counts only from that device (the door forces the device label; Subject.Ref is the caller's) — any other is refused and counted, on no row; agent is the fleet roster's liveness beside the verdict, and unknown (not in the roster, which does not replay) is never offline; no-reporters is absence, never synced. No third-party NTP/PTP feed is decoded. ⛔ SNMP is deliberately NOT folded into the device count — it/device/health has one profile (MIB-2) and no facility discriminator, so it would report every polled switch as a facility device; devices.observed is devices SEEN in the window, never an inventory. ?tenant=* FANS OUT (one customer's building is not another's).
GET /v1/sensor/sinkhole read (canRead), platform-scopeable (?tenant=) the DNS-sinkhole operating surface (N4 board M-1/M-2) — {since?, until?, total, enforcing, by_state{}, domains[{domain, target, request_id?, state, since?, mode?, status?, lifecycle{command,apply,verify,note}, source_rule?, case_id?, technique?, proposed_by?, approved_by?, reason?, actuator?, enforced, blocked_checkins, undo_us?, rolled_back?}], truncated, edges{resolvers, online, fleet, cap, agents[], note}, blocked{total, domains, clients, by_agent{}, note}, rollbacks{total, median_undo_us?, measured, note}, verification{state, needs[], note}, queued[{capability, item, state, why}]}; ?since&until (absent = lifetime), ?limit= bounds the RENDERED rows only. A JOIN of three streams that already exist: it/conductor/decision ⋈ it/conductor/action keyed on the domain:<name> target, the fleet's dns-sinkhole capability, and the agent's own it/enforcement/block · dns-sinkhole telemetry. ⛔⛔ THE BOARD'S "set verified in-sync" TILE IS SERVED AS A REFUSAL (verification.state = unverifiable): mote.DNSSinkhole.Sinkholed() is called by NOTHING, so no readback of a resolver's live set ever reaches the brain, and "verified" would relabel an acknowledgement as a verification. ⛔⛔ enforced IS DISTINCT FROM state: the shipped default actuator is a no-op, so a fleet with no real backend yields a full table of confident enforcing rows — enforced is false wherever the recorded actuator is the no-op, and ⚠ the wire value is mux(noop), never a bare noop (the conductor is configured with a kind-routing Mux, so an equality test reads every no-op row as a real containment). ⛔ The lifecycle strip is DERIVED from ONE terminal record, not four checkpoints — Conductor.actuate runs Apply then Verify and emits a single outcome, so there is no canary event, no verify event and no per-step timestamps. ⛔ An unrecognised status never falls through to enforcing (it stays ordered) — the shape that published a withdrawn hold as a failed action when the vocabulary grew. ⭐ blocked.total counts QUERIES and blocked.clients counts HOSTS: the carrier records a source as ip:port and a beaconing implant retries from a fresh ephemeral port, so the host tile is counted over the host half. ⭐ rollbacks.median_undo_us excludes no-op undos while still counting them — a no-op returns immediately and would publish a sub-millisecond "undo latency" to whoever is deciding whether a containment is safe to approve. ⚠ It is a READ: every control the board draws (sinkhole · roll back · withdraw) keeps using the conductor's own doors, so nothing world-changing gains a second route. ?tenant=* FANS OUT (one tenant's containment is not another's).
GET /v1/sensor/hunts?platform= agent (canAgent) the SIGNED hunt-content feed for linux\|windows\|darwin\|all — {feed:{platform, generated, osquery:{schedule}, yara[], key_id}, signature} — the osquery schedule the platform compiled + the runtime YARA registry, ed25519-signed with the platform key over the canonical bytes. Served even with Sensor actuation OFF (content sync ≠ actuation); the agent's -hunt/-scan-paths legs verify it against the pinned register-time key (fail-closed: a tampered/replayed feed keeps the last verified content) and execute host-side, shipping osquery result logs + YARA match reports back via /v1/ingest/raw
GET /v1/sensor/dlp agent (canAgent) the SIGNED protected-dataset feed (mote-dlp2) — {feed:{generated, datasets[], key_id}, signature} — the EDM/fingerprint indices an operator opted into edge distribution, in their salted-digest export form (per-dataset salt + digests/winnowed hashes; NEVER a raw value), ed25519-signed with the platform key (shares the register-time pin with the hunt feed). Served even with Sensor actuation OFF. The agent's -dlp-sync leg verifies it (fail-closed: a tampered/replayed feed keeps the last verified datasets) and ImportProtectedDatasets into the shared classifier both -dlp-paths (at-rest sweep) and -dlp-egress (inline guard) read — so edge DLP matches THIS record/document. Import is a full-snapshot REPLACE (a de-opted/removed dataset disappears from the edge on the next sync)
GET /.well-known/security.txt open the vendor trust center (O5, conduit/psirt + assembly/psirt.go; doctrine docs/06-governance-and-trust.md#trust-center) — the RFC 9116 disclosure pointer, generated FRESH from the declared program on every read (Expires always current). 404 until the program is declared (fail-closed: an inbox nobody triages must not advertise). Also on the public ingress
GET /v1/trust/vdp open the published VDP program — contacts, policy link, scope/out-of-scope, safe-harbor commitment, and the SLAs the platform pages ITSELF on (ack_sla_seconds first-response promise, disclosure_sla_seconds coordinated-disclosure clock). The whole declared profile IS the public policy
POST /v1/trust/report open + rate-limited anonymous vulnerability-report intake ({title, description, reporter?, component?, claimed_severity?, cvss_vector?}): per-IP + global token buckets (429 + Retry-After over-limit, feeding the burst/spray rules), 64 KiB body cap, hard field bounds, program-gated (404 until declared), open-queue cap (429 at saturation — never a silent drop). The 201 receipt returns the reference id + coordinated_disclosure_by — the program's own deadline. Lifecycle fields are server-owned: a hostile POST cannot arrive pre-disclosed or pre-scored
GET /v1/trust/advisories open published security advisories (EVENTLAKE-SA-YYYY-NNNN), newest first — published means public. ?version= folds affects_queried_version per advisory (the range check answers exactly or omits — never guesses over a pre-release tag)
GET /v1/trust/profile · PUT platform operator (PUT + step-up) read / declare the PSIRT program ({contacts[] (mailto:/https:/tel:), policy_url?, canonical?, encryption?, acknowledgments?, languages[]?, scope[]?, out_of_scope[]?, safe_harbor, ack_sla_seconds?, disclosure_sla_seconds?, advisory_prefix?}; defaults 72 h / 90 d / EVENTLAKE-SA). Declaring is the act that OPENS intake + security.txt — deployment-wide, admin + step-up
GET /v1/trust/psirt platform operator the pre-disclosure report queue with live SLA clocks ({reports[{…, sla:{ack_due, ack_overdue, disclosure_due, disclosure_overdue}}], open}) — platform-operator-only by design: pre-disclosure vulnerability detail is the most sensitive data the platform holds about itself
POST /v1/trust/psirt/{id}/transition platform operator one triage lifecycle step ({to ∈ acknowledged\|triaged\|confirmed\|fixed\|rejected\|duplicate, assessment?, cvss_vector?, cve?, cwe?, fixed_in?, note?}) through the fail-closed state machine. Confirming REQUIRES a CVSS vector (severity is derived by the v3.1 spec formula, never asserted); fixing requires fixed_in; disclosed is refused here — the advisory is the disclosure. Every transition is a durable full-snapshot it/platform/psirt-report event (D22); a report unanswered past the ack promise or undisclosed past the 90-day clock raises ONE SevHigh it/platform/psirt-sla → detect.PSIRTRule → alert (data-minimized: never the title/body)
POST /v1/trust/psirt/{id}/advisory platform operator + step-up PUBLISH the advisory for a FIXED report = the disclosure itself (outward-facing, irreversible): unset fields default from the report, the CVSS number is re-derived from the published vector, the id is minted sequentially per year, and the report transitions to disclosed. Durable it/platform/psirt-advisory
GET /v1/keyroots read per-tenant key-root contract (review §7a W42, conduit/byok + assembly/byok.go; 05-infrastructure-and-operations.md#deployment §10b): list root generations under the caller's scope (tenant-scoped; a Platform key sees every tenant's) — each {tenant, id, generation, mode ∈ platform\|byok\|hyok, state ∈ active\|retired\|revoked, fingerprint, created, rotated_from?, revoked?, revoke_reason?}. Never key material
POST /v1/keyroots approve + step-up enroll the tenant's key root ({mode?, material?}; tenant FORCED — a Platform key must name tenant): platform mints in-process (also auto-enrolled by the tenant's first ingested byte), byok imports the customer's base64 32-byte material (the sha256 fingerprint is customer-recomputable — proof the platform holds THEIR key), hyok binds a customer-held-KMS custodian (root never enters the platform). An active root refuses a second enroll (409 — rotate instead); ceremonies are durable it/platform/keyroot events (fingerprints/counts, never material)
POST /v1/keyroots/rotate approve + step-up mint the NEXT generation ({mode?, material?} — mode omitted keeps custody; platform→byok is the customer-custody upgrade path), REWRAP every live DEK under it, then destroy the old generation's material. Returns {root, rewrapped}
POST /v1/keyroots/revoke approve + step-up PROPOSE the tenant-wide crypto-shred ({reason} required): rides the conductor chain as the inherently-IRREVERSIBLE revoke-keyroot kind (BlastRadius = the live DEK count — the subjects whose data dies with the root), so the expected outcome is a HOLD (awaiting-approval + the request id). Nothing is destroyed by this call
POST /v1/keyroots/revoke/{id}/approve approve + step-up release the held revocation — the ceremony's second half (tenant-guarded; deliberately DISTINCT from the generic /v1/actions/{id}/approve: only THIS route destroys the root, so an approval routed elsewhere fails toward "the root survives"). On execute: every DEK under the root is unrecoverable → every encrypted span renders [erased], new mints refuse (ingest stores the destructive mask) until a fresh root is deliberately enrolled; SevHigh durable proof event; boot replay re-destroys the generation against a stale-backup keystore (trust-gated D22 heal)
GET /v1/keyroots/attestation read the platform-signed custody statement ({tenant, root, dek_count, generations, issued, key_id, signature}; a Platform key may pass ?tenant=): verifiable OFFLINE against the platform public key (byok.VerifyAttestation) — the attestable contract a customer hands their auditor
POST /v1/pam/sessions act PAM session brokering + recording (review §7a W44, pam/session.go + assembly/pamsession.go): open a brokered privileged session AS THE CALLER ({target, protocol? ∈ ssh\|rdp\|shell, scope, reason?}) riding an ACTIVE JIT lease covering scope — no covering lease ⇒ 403 AND a High it/pam/session-denied → alert (the conductor is never even asked). With a lease the open passes the guardrail chain (broker-session, reversible — terminate is the undo; Evaluate + self-perform, so tomorrow's session to the same host is never an idempotency no-op): 201 {status:opened, session} with recording dispatched to the target's owning Sensor agent (record-session command; recorder:"none" recorded HONESTLY when no agent owns the target), 202 {status:held, request_id} for a crown-jewel target, 200 a recorded refusal
GET /v1/pam/sessions read the tenant's brokered sessions (all states, newest first — chunk data never rides the list) {count, sessions[{id, principal, target, protocol, scope, lease_id, recorder, state ∈ active\|closed\|terminated, chunks, bytes, last_seq, last_hash, close_reason?}], pending[]} — pending is the held-open approval inbox
POST /v1/pam/sessions/{id}/approve approve + step-up release a HELD open (id = the conductor request id): the chain RE-RUNS with the approval attached (an engaged kill switch still denies, fail-closed), and the vault re-checks the lease AT THE GRANT MOMENT — a hold that outlived the JIT window is 409 (re-elevate and re-request; an approval never resurrects a dead elevation). Tenant-guarded: another tenant's hold is 404
POST /v1/pam/sessions/{id}/terminate act the operator kill ({reason?}): the session flips terminated (SevHigh on the lake) and a terminate-session command is relayed to the recording agent. The safe direction — an analyst can always pull it
GET /v1/pam/sessions/{id}/recording evidence-export playback, evidence-grade: the ordered chunks read off the lake through the masked + audited View path, the custody verdict (chain_ok — the sha256 hash chain replayed from the session genesis; broken_seq names the first bad link), and the reassembled output transcript. The keystroke stream is the most sensitive record the platform holds — admin-only by design
POST /v1/sensor/session/chunk agent (canAgent) the agent recording leg: the Sensor agent capturing a brokered session at the HOST boundary (the PTY, where the stream is plaintext — no protocol MITM) streams {session_id, seq, stream ∈ input\|output\|meta, data (base64 ≤ 32 KiB)}. STRICTLY sequenced custody: the broker hash-chains each chunk into a sealed it/pam/session-chunk event; a sequence break / 16 MiB session cap / lease expiry TERMINATES the session (409 + terminated:true — no recording ⇒ no session, never a silent gap); an oversized chunk is 413 (re-chunk, same seq — nothing lost). Tenant forced
POST /v1/sensor/session/close agent (canAgent) the normal end ({session_id}): the broker emits the custody RECEIPT (it/pam/session-closed with the final chain head + totals) a verifier replays the chunk chain against
POST /v1/actions act propose an action (a reason is REQUIRED for the twelve disruptive kinds in actions.ReasonRequiredKinds — 400 without one, on gRPC and MCP too; >2000 bytes refused on every kind, 2026-09-19n) → conductor gate → Outcome (executed / awaiting-approval / denied). The caller's risk attributes (crown_jewel, reversible, confidence) are re-derived server-side (CMDB criticality, action kind, [0,1] clamp), so a proposer cannot under-report risk to skip a hold. compensates:<request id> files the undo of a previously EXECUTED action (the operator's release-from-vault / un-isolate door): same kind+target, actuation routes to the actuator's Rollback, the SAME chain runs, inherently-irreversible kinds refuse, and the in-effect mark is tenant-keyed — a forged/cross-tenant id is a refusal
GET /v1/actions/pending approve list this tenant's held actions
POST /v1/actions/{id}/approve approve release a held action (same-tenant only). 403 separation_of_duties when the approver is the proposer and the tenant requires a second approver (2026-09-19n) — also on the approvals resolve, the BYOK revoke approve, and gRPC ApproveAction (PERMISSION_DENIED)
POST /v1/sensor/exceptions/{id}/revoke approve (no step-up) withdraw a desktop exception request ALREADY GRANTED (2026-10-20x, queue row 415): the Compensates undo through the same gate (Platform.RevokeDeskException); body {reason} required (400 sensor.exception.revoke_reason_required); {revoked, outcome}. A request never granted, denied, already revoked or another tenant's → 404 sensor.exception.not_granted; another action kind → 409 sensor.exception.not_an_exception. The Sensor then reads revoked on GET /v1/sensor/exceptions and closes the DNS name it had opened
GET /v1/actions/policy approve the tenant's four-eyes rule for a PERSON's proposal (2026-09-19n): {require_second_approver, disable_requested_by?, disable_requested_at?}. Tenant forced from the key; OFF by default
PUT /v1/actions/policy approve + step-up {require_second_approver: bool} (a missing field is 400, never read as OFF). ON is one step (200). OFF takes two admins: the first OFF is a request (202, rule still ON), the same admin confirming is 403, a different admin completes it (200). Every step is a durable it/platform/approver-policy record
GET /v1/actions/kill approve read the engaged kill-switch scopes (masked to the caller's tenant)
POST /v1/actions/kill approve + step-up emergency stop: halt automated action for a scope ({"scope":"*"|"<kind>"|"tenant:<t>"}; empty = caller's tenant). A tenant admin trips only its OWN tenant; only a Platform super-admin the fleet-wide */per-kind stop
DELETE /v1/actions/kill?scope=… approve + step-up lift a kill-switch scope (same scoping rules)
GET /v1/compartments read compartment / clearance controls (SR14-P5) — a tenant's registered need-to-know labels + the grant roster: {compartments:[{label, two_person, created}], grants:[{who, label, granted_by, created}]}. The roster is audit material, so it is a plain (tenant-forced) read. An event carrying attribute compartment:<label> (comma-separated for multi) is dropped from every human read surface (events/export/tail/investigate/lineage — REST, gRPC, MCP, SQL) unless the viewer holds every label; an UNREGISTERED label is inert (emitters cannot self-hide); detection reads the lake raw (never blinded); tier clearance never substitutes for a grant
POST /v1/compartments admin (canManageCompartments) + step-up register/update a label: {label, two_person?} — the admin act that turns a label into a control. two_person:true additionally gates every read of that compartment behind the request/approve/execute flow below. Invalid label (1–64 of A-Za-z0-9._-) → 400
POST /v1/compartments/grant · /revoke admin (canManageCompartments) + step-up {who, label} — grant/revoke need-to-know for a principal identity (durable it/platform/compartment-grant events, replay-rebuilt). Unregistered label → 404; unknown grant on revoke → 404
GET /v1/compartments/requests read the two-person approvals queue + its history (tenant-forced): {count, requests:[{id, who, label, entity?, since?, until?, justification, status ∈ pending\|approved\|consumed, approver?, created, approved?}]}
POST /v1/compartments/requests read file a two-person read proposal as the AUTHENTICATED principal (never a body identity): {label, entity?, since?, until?, justification} → 201. The requester must already HOLD the grant (the second person authorizes an already-cleared reader); non-two-person label → 400; no grant → 403; justification required (the audit trail is the point)
POST /v1/compartments/requests/{id}/approve approve + step-up the second person's release: the spine additionally enforces different person + approver holds the grant. Pending requests expire after 24 h → 410. Same-tenant only (cross-tenant reads as 404 — no existence oracle)
POST /v1/compartments/requests/{id}/execute read run the approved read ONCE — original requester only (403 otherwise), inside the 1 h approval TTL (→ 410), consumption durably recorded BEFORE rows return (a replayed history can never re-open a spent read). Returns {count, events} through the NORMAL tier masking + access audit: the approval unlocks the compartment, never the clearance ladder. Already consumed → 409
POST /v1/validation/engagements admin mint + KMS-sign a Rules of Engagement (Validation §11b; tenant + authorizer forced from the operator). ttl_seconds (default 3600) is refused 400 when negative or above 604800 (validation.MaxEngagementWindow, 7 days), on the int before the multiply; Engagement.Sign refuses any window over that, or with an unset end, for every signer (2026-09-19bb)
GET /v1/validation/engagements read list this tenant's engagements
GET /v1/validation/posture read preview the live-projected validation posture (CMDB assets/services + windowed vulnerability events; ?window_seconds= bounds the lookback)
POST /v1/validation/campaigns act run a validation campaign under an engagement (an omitted posture is projected live); every above-passive probe is conductor-HELD and rides /v1/actions/pending + approve
POST /v1/validation/campaigns/{id}/coverage act purple-team scoring: which executed techniques did detection miss (gaps → coverage-gap events)
GET /v1/validation/runs/interrupted read (scopeable) campaign runs a restart cut short (hot-upgrade audit Gap 10): {runs:[{run_id, engagement_id, tenant, mode, started_at}], count}, oldest first. ⛔ Listed for a human, never resumed — a half-run attack simulation re-runs only through the gate
POST /v1/validation/runs/{id}/acknowledge act closes one interrupted run once dealt with (tenant forced); an unknown id, another tenant's run or a run still live → 404 validation.run.not_interrupted (crucible.run.not_interrupted until 2026-10-26w, still published as message_id_retired until §6ez)
POST /v1/detonate act detonate a suspicious file (content_base64) or url on demand → verdict + ATT&CK behaviors + IOCs; the report lands tenant-stamped on the lake (readable via /v1/events?category=sandbox, malicious verdict → alert → case)
POST /v1/acquire/memory act volatile-memory acquisition (Cinder, W24) — image one host's RAM behind the Acquirer seam. OFFLINE (default): content_base64 carries an already-captured dump (raw / LiME / ELF-core / crash-dump), hashed + mapped without executing anything. LIVE (method:"live", no content_base64; needs the deployment's live backend — EVENTLAKE_MEMCAPTURE_COLD_DIR ± _TOOL): images the RUNNING host (/proc/kcore, or winpmem/AVML/LiME), streaming the raw image to the cold/WORM tier. In BOTH cases the raw bytes NEVER return and NEVER ride the lake — the response/manifest event (it/forensics/memory, activity acquire) carries only hash + size + format + memory-map + cold_store_key + chain-of-custody digest; a truncated capture is an honest High/failure. A live request with no live backend (or an OS it can't image) is a 400, never silently offline-imaged
POST /v1/analyze/memory act run the memforensic ANALYSIS legs over an acquired image (content_base64 + optional manifest/sha256 chain-of-custody re-verify, pslist, legs): pool-reconcile (DKOM-hidden process, T1014) · unbacked-code (shellcode stub / reflective PE, T1055+T1620) · cred-carve (key/credential material, T1003.001). Findings land as it/forensics/memory events (activity = finding kind, analysis=true) → High+ fire detect.MemoryAnalysisRule → alert → case; values are NEVER echoed (class+offset+count only)
POST /v1/evidence/artifact/{sha256} ingest full-fidelity forensic preservation (the Sensor triage-collection follow-on) — the Sensor agent streams ONE collected artifact's RAW bytes (octet-stream, ?host&case&triggered_by) into the cold/WORM evidence tier under the content-address artifact/<sha256>. Same door the agent ships collections through (canIngest), tenant-forced. The server RE-HASHES the stream and REFUSES it if the bytes don't match the declared {sha256} (content-addressed integrity); commit is write-once/idempotent, capped at 256 MiB. The raw bytes NEVER ride the lake and NEVER touch the meter — only a sealed it/forensics/preservation audit record does (hash + size + host + cold_store_key + custodian, causal_parent = the triggering action). Refused honestly (400) when no cold tier is wired
GET /v1/evidence/artifact/{sha256} export-evidence evidence EXPORT — streams a preserved artifact's raw bytes back (application/octet-stream, nosniff, attachment) — the EventLake twin of CrowdStrike RTR's "download investigation package". canExportEvidence (admin-only, like the /v1/evidence bundle export), tenant-forced AND same-tenant-guarded in the spine: a tenant may retrieve only a hash it PRESERVED, so a globally content-addressed store never leaks one tenant's evidence to another. A hash this tenant did not preserve (or an absent object) is an indistinguishable 404 (no cross-tenant existence oracle, no audit on the refusal). Every successful export lands a sealed it/forensics/evidence-access audit record (who exported what, when)
GET /v1/deception read the deception MONITORING board (N10 — the "monitor" leg) over a trip window (?since&until; absent = lifetime): the tenant's planted decoys JOINED with their it/deception/triggered events → {tokens, tripped, untripped, trips_total, by_kind, status[{id,kind,label,value,severity,planted_at,trips,last_trip,last_actor,last_field}], recent[{time,token_id,kind,label,actor,actor_kind,src_ip,src_host,field,event_id}]}. status is most-tripped-first; recent is newest-first (capped 50), carrying the toucher's context. tokens==0 is the keep-the-mock signal. (Mint/place/rotate stay POST/DELETE/rotate /v1/honeytokens.)
GET /v1/recovery/plan read cyber-resilience recovery plan (N12): the blast-radius-ORDERED, backup-state-aware recovery sequence for one compromised asset — ?root= (required, kind:ref), ?case=, ?hops= (ring bound; 0 = unbounded), ?since/?until (backup-state window). Waves = Timeline dependency rings (ring 0 = the root restores first; crown jewels lead a wave); each step references the REAL folded it/backup/decision state (last-good / verified / immutable / RPO gap) and a step with no safe source is blocked with the reason (tampered protection is STICKY — ransomware deletes the backups first). Read-only
POST /v1/recovery/execute act run the plan: one conductor-gated restore-asset action per RESTORABLE step, wave-ordered — every one is IRREVERSIBLE by declaration so the guardrail chain HOLDS it; an admin releases the waves via /v1/actions/{id}/approve. Blocked steps are never proposed. Body {root, case?, hops?}; playbook tasks land on the named case; each run appends one it/recovery/plan control-plane record (OutcomeFailure when nothing is restorable — the honest "no recovery exists" signal)
GET /v1/exposure read external attack surface (EASM / Exposure, C24): the board's ExposureReport — the last assessed surface folded from the tenant's it/exposure/* window (?since/?until). Findings by kind (dangling-DNS · cert expiry/self-signed/mismatch · exposed sensitive service · shadow-IT · kev-on-exposed) + severity rollup + scope-coverage. Read-only
POST /v1/exposure/scan act run a discovery pass and emit the exposure findings. With a {surface:{dns,certs,services}} body it assesses that out-of-band surface (an imported scan / CT export / connector output); an empty body drives the wired scanner. The tenant's ownership-verified scope gates what can become a finding — an unverified/opted-out asset produces nothing (fingerprint-only, never exploited). High/Critical findings page via detect.ExposureRule
POST /v1/exposure/watch act run a brand/lookalike watch pass (the pre-compromise plane, 2026-07-14) and emit the lookalike findings. With an {observations:[{domain,source,cert_issued,live,login_form,…}]} body it assesses that out-of-band set (an imported CT export / registrar report); an empty body drives the wired easm.WatchSource. Only ownership-VERIFIED apexes are guarded; own infra + the scope allow list are skipped. Evidence ladder: registered→Medium lookalike-domain (T1583.001, board row) · cert→High lookalike-cert (T1588.004) · staged→High/Critical lookalike-staged (T1608.005) — the activation rungs page via detect.ExposureRule. Returns the BrandReport
GET /v1/exposure/scope read the tenant's declared discovery scope (apex/CIDR/ASN + per-asset verification/opt-out state + the brand-watch allow allowlist)
POST /v1/exposure/scope manage-detections declare/replace the discovery scope (fail-closed validation — a bad CIDR is 400). Declaring does NOT authorize scanning; each asset must still be verified
POST /v1/exposure/scope/verify approve mark a scope asset ownership-verified ({kind, value, method} — dns-txt/http-file) — the act that puts it in scope for discovery
GET /v1/ctem read continuous threat exposure management (CTEM, C44): the board's fused Report — the last fusion cycle folded from the tenant's it/exposure-mgmt/* window (?since/?until). The prioritized exposures + the funnel (fix_now/validated/urgent/exploitable/crown_reachable/kev/internet_facing counts). Read-only, no re-fusion
POST /v1/ctem/assess act run a CTEM fusion cycle — read the vuln (it/vulnerability/prioritized), easm (it/exposure/finding), and Validation (it/security/validation/finding) signals, join with Timeline reachability to a crown jewel, fuse per asset, and emit the prioritized exposures. Only the fused apex (fix_now=true — validated-exploitable reaching a crown jewel) pages via detect.CTEMRule; the lesser tiers are the board worklist
GET /v1/entities read entity ontology (SR14-P1, 2026-07-21): the tenant's world-entity registry (person · organization · location · document · event-of-interest + any technical kind), ?kind= filtered → {count, entities:[{key, kind, ref, name, attrs, aliases, sources, first_seen, last_seen, count}]}. Projected per tenant from the it/entity/record + it/entity/relation streams. Tenant-forced
GET /v1/entities/entity read the MERGED-identity view of one identifier: ?key=kind:ref (required) → {query, canonical, aliases:[…], entity, relations:[{from, rel, to, source, since, count}]} — relations are gathered across the identifier's whole alias/same-as set, so a relation asserted against an account surfaces on the person. 404 for an identifier the ontology knows nothing about. Tenant-forced
GET /v1/entities/resolve read point-in-time identity resolution riding SR1: ?id= (a kind:ref key OR a bare IP) + ?at= (RFC3339, omitted = now) → the same shape plus binding (the lease interval used) when an IP was the query — the address resolves to its holder AT t (interval-correct), then that holder's canonical identity (alias-correct). 404 = honest failure. Tenant-forced
GET /v1/entities/schema read the per-kind attribute contract (SR14-P1 remainder, 2026-07-21): {kinds: {person\|organization\|location\|document\|event-of-interest: [{name, type, synonyms, doc}]}, quality: {rejected_relations, nonconforming_attributes}} — the canonical attribute vocabulary a dossier drawer renders (types are FORMAT checks: email · E.164 phone · date/time RFC 3339 · lat/lon · ISO 3166-1 alpha-2 country · hostname · sha256) plus the tenant's fail-honest data-quality tallies (mistyped relations rejected by the endpoint-kind table; attribute values refused by the kind schema). Records fold through ontology.NormalizeAttrs on ingest, so registry attrs arrive canonicalized. Entities also gain CMDB-bridged knowledge with no extra ingest: every asset inventory snapshot asserts organization:<owner> ─owns→ host and host ─located-at→ location:<site> (cmdb-exclusive: an owner/site change supersedes the previous cmdb-sourced edge; analyst assertions survive). Tenant-forced
GET /v1/resolve read entity resolution (SR1, 2026-07-20): who held an IP at a point in time — ?ip= (required, validated) + ?at= (RFC3339; omitted = NOW/the open binding) → {ip, at, resolved:{entity, mac, hostname, user, source, from, until}\|null, history:[…]}. Projected from the tenant's it/network/lease stream (the dhcp connector: DHCP servers + VPN assignment forwarders) by the interval-valid asset.Resolver, so attribution survives lease turnover / VPN pool reuse instead of a last-write-wins CMDB join. resolved:null = an honest miss (no binding covers the instant), never the nearest guess. Tenant-forced
GET /v1/geo/fences read geospatial fence registry (SR14-P3, 2026-07-21): the tenant's active geofence definitions — {fences:[{id, name, lat, lon, radius_km, on: enter\|exit\|both, entity_prefix?, severity, time}]}. Fence WRITES are events, not a config door: POST /v1/ingest an it/geo/fence (Category geo, attrs fence_id/name/lat/lon/radius_km [0.01–5000 km, fail-honest] + optional on [default enter] · entity_prefix (node-key scope, e.g. person:) · severity [default high]; revoked:"true" removes). Crossings emit it/geo/breach findings → the geo-fence-breach alert rule (fence severity inherited; once per crossing, never while inside). Tenant-forced
GET /v1/geo/tracks read per-entity movement tracks (SR14-P3): ?entity=kind:ref (optional — omit for all) + ?since/?until (RFC 3339) → {tracks:[{entity, points:[{entity, lat, lon, label?, source, time, event_id}], total_km, max_kmh, dropped?, resolved?, mixed_precision?}]}. Points come from PRIMARY telemetry only (meter.IsIngress — badge/auth/vision/MDM/network speaking the Src.Geo/"lat,lon" vocabulary, explicit lat/lon attrs, or the geoip seam over Src.IP); platform-derived echoes (breach findings, alerts) never double-count an observation. dropped reports the oldest points shed past the 5000/entity cap — truncation is never silent. Precision provenance (2026-07-22): each point's source is reported (the telemetry carried coordinates) or resolved (derived from Src.IP through the geoip seam — network geography, not an observed position); resolved counts the latter and mixed_precision flags a track holding BOTH classes, which is the shape that fabricates movement — a GPS fix and a city centroid minutes apart are tens of km apart by construction, so max_kmh can read as travel nobody made. Reported, never suppressed: the legs are real, only the arithmetic across them is weaker than it looks (travel physics belongs to ueba). Tenant-forced
GET /v1/geo/copresence read co-presence meetings (SR14-P3): "who was where, together, when" — ?radius_km= (default 0.5) + ?window= (Go duration, default 15m) + ?since/?until → {meetings:[{a, b, start, end, min_km, count, lat, lon, resolved?}], truncated?} — entity pairs observed within the radius of each other inside the pairing window, consecutive encounters merged into one episode (a < b lexically; deterministic ordering). resolved marks an episode with a geoip-derived point on either side (2026-07-22) — the default 500 m radius is far finer than any IP resolution, so two clients behind one corporate VPN exit resolve to the identical centroid and "meet" at 0 km without sharing a room; the episode still reports (a shared egress is itself a fact) but it is network geography, not a rendezvous. Primary telemetry only, like tracks. Tenant-forced
GET /v1/geo/status read geo-seam provenance + coverage (2026-08-19): what IP→location table this node loaded and how much of the caller's own authentication telemetry asked it → {dataset:{loaded, kind, bundled:false, accounts, source?, digest?, ranges|null, loaded_at?}, coverage:{records, source_supplied, no_source, needs_seam, resolved, unresolved, resolve_rate?}}. ?since/?until bound the coverage window. The two halves are deliberately not merged: dataset is a property of the NODE (one boot-loaded table, identical for every tenant, written ONCE even on an estate read), while coverage is measured over ONE tenant's events and therefore FANS OUT on ?tenant=/?tenant=* — two customers' resolve rates averaged is a rate nobody is accountable for. ⚠ resolve_rate is ABSENT, never 0, when no resolver is installed or nothing asked the seam: the denominator is the records that ASK (a source IP and no source-supplied location — an IdP that stamps its own coordinates never consults the table), so 0/0 would render a perfectly-served estate as a broken feed. kind is none|range-table|custom; an operator-plugged resolver that cannot account for itself reports ranges:null (UNKNOWN), which is not the claim 0 makes. bundled is always false — the platform ships no geolocation dataset, so the digest names the OPERATOR's artefact. canRead; tenant-forced
GET /v1/timeline read the at-scale provenance graph (2026-08-19): level-of-detail nodes/edges over a window → {since, until, cluster_by, nodes, edges, clusters:[{key, nodes, edges}], focus?:{key, found, hops, nodes:[{key,kind,ref,count,first_seen,last_seen,hop}], edges:[…], truncated?}}. ?since/?until (the time brush) · ?cluster=kind\|action (the resolved key is always ECHOED as cluster_by — the board ships an "auto" control and must be told what it got) · ?focus=<kind:ref> · ?hops= (default 2, max 4) · ?max_nodes= (default 500). ⭐ THE BOUND IS THE CLUSTERING, NOT A ROW CAP: nodes/edges are EXACT over the whole window while only the focus subgraph is materialized, so nothing has to be dropped for the payload to stay small — and Σ clusters[].edges == edges holds because an edge is attributed to its FROM node's cluster only. ⚠ focus.found=false is an ordinary 200, never a 404: an entity that was quiet in this window is the board's "widen the range" state, and a 404 would teach an analyst that a correct id was wrong. ⛔ Distinct from the two nearest doors — GET /v1/investigate is an ENTITY-scoped timeline (400s without ?entity, no topology) and GET /v1/assets/graph is the CI DEPENDENCY graph (structural runs-on/talks-to), not temporal event provenance. FANS OUT on ?tenant=/?tenant=* (merging two estates' graphs would put one customer's host in another's blast radius). canRead; need-to-know gated; node refs are redacted to the reader's clearance while the KEY stays verbatim (a board deep-links on it)
GET /v1/insider/risk read explainable insider risk (2026-08-19c, W10): one score per PSEUDONYMOUS subject with every factor that built it → {config:{weights,unweighted,cap,high_band,medium_band,repeat}, subjects:[{handle,role,score,band,factors:[{source,kind,title,count,points,declared}],without:{source→score},resolved}], unresolved, handles:{keyed}, reveal:{available,reason}}. ?since/?until. ⭐⭐ THE SCORE IS THE SUM OF ITS FACTORS — no hidden weight, no model — and the weight table is served with it so a reader can redo the arithmetic; a number an HR review cannot reconstruct cannot be defended. ⭐⭐ without is RECOMPUTED, never score − points: the total is capped, so subtraction is wrong exactly when the score is high (measured live: raw sum 118 → score 100, without.hr = 94, where subtraction would say 76). ⛔ NO NAME OR ENTITY KEY LEAVES THIS DOOR FOR ANY CALLER, at any clearance — subjects appear by coarse role + a keyed pseudonymous handle (p-NNNN, HMAC per tenant). reveal.available is FALSE: the board's dual-control reveal is not built, and the door says so rather than letting a UI render an affordance that cannot work. ⚠ handles.keyed=false means the pseudonyms are PER-PROCESS (no BYOK key roots wired) — internally consistent, different after a restart, so a written-down handle is not tomorrow's. ⚠ resolved=false means the row MAY BE A FRAGMENT: the arms spell a person differently (user: vs principal:), so an unregistered ontology alias splits one human across two handles and understates both. ⚠ The HR leg is a DECLARED Workday activity vocabulary matched EXACTLY on cloud/audit/workday — never a category or substring match, or a CloudTrail read scores as a resignation. Nothing here emits, opens a case or actuates. canRead; need-to-know gated; FANS OUT on ?tenant=
GET /v1/research read Lens in-platform research/browse (Phase 2, 2026-07-21): full-text + faceted BM25 search over the knowledge the lake ALREADY holds — advisories (it/platform/psirt-advisory), prioritized vulnerabilities (it/vulnerability/prioritized), TIP indicators (it/intel/indicator; a revoked indicator's row is REMOVED), Validation-confirmed exploits (it/security/validation finding). Params (all optional; empty text = faceted browse, severity-then-recency): text · domain (repeatable/comma: advisory\|vulnerability\|intel\|exploit) · cve · technique · actor · source · min_severity · since/until (RFC 3339) · limit≤200 · offset → {total, hits:[{…document, score, snippet}], facets:{domains, severities, sources}} (facets over the FULL match set, pre-paging; deterministic total-order ranking). An unknown domain/severity is 400, never an empty page. Tenancy split at fold time: advisories + TIP are platform-scoped (every tenant's reader sees them), vuln/exploit docs are tenant-owned — a reader sees its tenant's + platform docs, never wider. NOT web search: lake-resident content only (conduit/research). Rides the D23 query ceiling; not metered
GET /v1/ask read Gen-AI copilot Q&A (?q= the natural-language question): the oracle plans a store.Query (tenant-FORCED), retrieves the grounding events, and returns the grounded assistant.Answer — {question, query, count, summary, citations[], knowledge[], suggestions[], grounded, model}. Extractive by default (computed facts only, model:"extractive"); a configured EVENTLAKE_LLM_* model yields RAG prose with the SAME citations (fallback to extractive on any model failure). knowledge[] (2026-07-11, P1) = the platform-catalog RAG context — {id, kind: detection\|technique, title, note, score}, citable refs into the detecteng corpus + framework matrices ("explain this detection" / "what technique is this"); product knowledge, never tenant data. Any suggestions[] are PROPOSALS only — execution routes through POST /v1/actions. Every Q&A leaves the it/assist/* audit trail; every model call self-emits it/ai/llm-request
GET /v1/agents read the agent run-door (genai-agentic-integration §4.3, BUILT 2026-07-12): the DECLARED A1–A10 roster — {id, agent (NHI ref), rung, description, input, input_required, acts} per entry. Self-describing: input names what the one primary argument means (goal/case/matrix/topic/question), acts tells the caller which permission a launch needs
POST /v1/agents/{id}/run read / act (per roster) launch one roster agent by id — body {input?, args?} (args = the optional shaping map only validation-operator/posture-remediator read; the retired id crucible-operator still launches the validation operator — an alias read forever, 2026-10-12j), window via ?since/?until. Identity (tenant/role/who) FORCED from the principal — a body field can never widen scope. RBAC splits on the roster's own acts declaration: investigator + deep-answer launch at read clearance (like /v1/ask); every proposal-filing or record-writing agent requires act. Synchronous (agents are hard-budgeted; the air-gap planner is deterministic) → the AgentLaunch envelope {id, agent, run:{conversation, final, stopped, steps[], proposals[], violations[], tokens_used}, deep_answer?}. NOTHING an agent does executes — an acting run's outcome is a conductor HOLD released via POST /v1/actions/{id}/approve. Unknown id 404; missing required input 400. Rides the D23 query ceiling
GET /v1/agents/runs read past first-party agent runs (?since/?until/?limit≤200) — a FOLD of the it/ai/agent-lifecycle spawn/terminate telemetry into {agent, conversation, started, ended, stopped, tools[]} rows, newest first, through the same masked + audited ViewPage path as /v1/events (no private run store; the lake is the record). Third-party mesh telemetry (decode/genai-normalized customer agents) is excluded — this lists the platform's OWN agents
GET /v1/onboarding read the A7 connector-onboarding review surface (2026-07-13): {count, drafts[], connectors[]} — the caller-tenant's PARKED onboarding drafts (each behind a held onboard-connector proposal; request_id is what POST /v1/actions/{id}/approve releases) + the LIVE agent-onboarded connectors (request_id empty). Each OnboardingSummary row carries what the releasing operator judges: connector_id · source · rule · parser · category/type · govern · mapped_slots{slot→field} · default_rules[{id,title,severity}] · samples (the fixture COUNT) + the draft's embedded governance{samples, fields, governed, ungoverned[], collided[]} lexicon report — the spellings to alias BEFORE release (the /v1/lexicon feedback loop at draft time). Raw sample frames NEVER leave the spine (they may carry PII; the durable draft event keeps the fixtures for the gate). Tenant-forced, strict equality — the same scope as the pending-actions queue
GET /v1/parse-rules · GET /v1/parse-rules/{name}/preview read the shipped parse-rule LIBRARY and its dry run (2026-09-20at) — docs/reference/text-log-parser-program.md. The catalogue lists every vendor-format template the platform ships (name · source_version · parser_kind · category/type/activity · anchors[]): name is what a binding names, source_version is the DEVICE VERSION the rule was written against (D08-33), and anchors are the fields that decide whether it claims a frame — published so an operator can judge a template BEFORE attempting a binding, instead of learning it from a refusal. The catalogue takes no tenant: it is platform content, identical for every caller. The preview answers "would this template read my feed?" over the caller-tenant's OWN retained frames (?source=<source id>, the ids on /v1/sources/undecoded), returning frames · parsed · claimed · anchors · claims · reason · governance and filing nothing — the binding itself stays the conductor-gated onboarder door. ⭐ A refusal is a 200 with claims:false carrying the binding door's own sentence: "no, and here is why" is the answer a dry run was asked for, and an error there would be indistinguishable from a mistyped source. parsed and claimed are DIFFERENT numbers — a kv parser structures almost any text, so only a frame yielding one of the rule's anchors counts as read. Raw frames NEVER leave the spine, the same rule /v1/onboarding holds. Platform-scopeable to ONE named tenant; * is refused, because a source id is unique only inside a tenant
GET /v1/recall read incident recall (vector retrieval, C11): semantic k-NN over the tenant's alert + case history — ?q= free text, ?k= results (default 10, cap 100). Ranked hits ({id, kind: alert\|case, title, time, score}); a case surfaces ONCE under its case id. A pure read over the lake-folded per-tenant ANN projection (clean-room HNSW + the Embedder seam; a semantic model plugs in via EVENTLAKE_EMBED_*, else the hash embedder — air-gap default) — nothing emitted, not metered
GET /v1/recall/similar read similar-to-an-incident pivot: ?event=<id> (an alert event id or a case lifecycle event id, resolved ONLY inside the caller's tenant — 404 otherwise) + ?k= → the nearest past incidents, the source excluded. The alert drawer's "similar past incidents" panel
POST /v1/model/corpus manage-detections own-model pilot (P4) — cut a training corpus from the caller's tenant: its CONSENTED, classify-masked lake material (dispositions, alert narratives) + PLATFORM knowledge (detecteng catalog + framework techniques) + the llmsec catalogs as adversarial REFUSAL negatives. Returns the manifest {digest, pairs, masked, sources, tenants, dropped_consent, dropped_empty, dropped_dup} (never the pairs). Emits a durable it/model/corpus. A tenant with no model-training consent contributes NOTHING (only platform knowledge is cut)
POST /v1/model/canary manage-detections shadow-canary the wired CANDIDATE (EVENTLAKE_MODEL_CANDIDATE_*) against the incumbent copilot model over the golden set (grounding/compile/injection/refusal). Returns {comparison, candidate, incumbent} — comparison.recommend is true only if the candidate cleared its OWN gates AND regressed no gate the incumbent met. 409 if no candidate is wired
POST /v1/model/register manage-detections record a candidate Artifact ({name, version, base, provider, endpoint, weights_hash, corpus_digest, eval_digest}) — the ID + integrity digest (binds base+corpus+eval+weights) are stamped server-side. Emits a durable it/model/artifact snapshot
POST /v1/model/promote manage-detections the Compiler-governed activation: {id, action: promote\|rollback}. Promoting DEMOTES the previously-active model (at most one active per tenant); both changes are durable (D22 replay rebuilds the active model). Unknown artifact ⇒ ok:false
GET /v1/model/registry read list the tenant's registered model artifacts + the active (promoted) one, if any — a pure read of governed model state
GET /v1/segment read intent-based segmentation (Firewall, C31): the fabric's FabricReport — the current revision, per-enforce-point rollout status, and any drift, folded from the tenant's it/segment/* window (?since/?until). Each rollouts[] row names the ONE backend that served that point (the device it is bound to, or recorder; the report's enforcer stays the wave's provenance), a failed row carries cause ∈ not-armed · not-bound · jump-absent · refused-foreign-rule · refused-ruleset · untrusted-device · unreachable · unverified · unsupported · device-error, and a drifted row drift_kind ∈ jump-absent · foreign-dispatch-row · dispatch-not-intact · no-owned-rules · fingerprint · digest-mismatch · orphan · unknown — tokens derived from the error's type / the attested token, never from prose; a value outside either set is served absent (2026-10-03m). Read-only
GET /v1/segment/policy read the tenant's declared intent policy (identity-term allow/deny intents) + fabric topology (workload inventory + enforce points), plus etag — the compare-and-swap token both setters below require back as if_match. One token covers the WHOLE record, so it moves whichever half was written. following[] (FW2, 2026-10-26g) lists each endpoint that follows an enrolled Sensor (endpoints[].sensor, set INSTEAD of ip) and is not fully programmed: {index, sensor, reason, ip?}, reason not-enrolled · no-address · too-many-addresses · address-contested (two Sensors report it: programmed for nobody) · address-declared (an operator typed it for another endpoint). Only addresses come from the host; identity and labels stay operator-set. People endpoints (2026-10-26h): endpoints[].people[] + lease_from[] (required together, never beside ip/sensor) follow the CURRENT addresses those people hold per the named lease sources only (the source is the submitting principal, forced at ingest); give the endpoint a role label and write policy against the label. Two persons on one address are enforced for neither; a role with nobody connected reports no-address
POST /v1/segment/policy manage-detections declare/replace the intent policy (fail-closed validation — an unknown action / duplicate id / bad selector is 400). The revision auto-increments (monotonic). if_match is the etag this edit was computed against: a mismatch is 409 and NOTHING is written (the setter replaces the whole document, so applying a stale edit would silently discard the other writer's). Absent if_match means "I expect nothing declared yet" — a blind replace of an existing record is 409, not an overwrite. The response carries the stored policy plus the record's NEW etag, so edits chain without a re-read
POST /v1/segment/endpoints manage-detections declare/replace the workload inventory + enforce points the policy compiles against ({endpoints:[{identity,ip,labels}], points:[{id,kind:host|gateway,governs}], if_match}). Same compare-and-swap contract and same 409 as the policy setter — one record, one token, so a topology write invalidates a policy writer's token and vice versa. Fail-closed on a duplicate enforce-point id (it would compile to two rulesets for one point) and on a workload naming nothing at all (no selector could ever match it); a governs entry naming an undeclared identity is deliberately ACCEPTED — that is the same soft, board-reported condition as an unresolved intent
POST /v1/segment/distribute act compile the policy into per-enforce-point rulesets and — if the conductor allows (DeploySegmentPolicy, BlastRadius = live enforce-point count, reversible) — push each to the fabric. Returns {deployed, decision, result}; a held/denied deploy pushes nothing. A rollout FAILURE pages via detect.SegmentRule
POST /v1/segment/attest act read back each enforce point's live ruleset digest and flag DRIFT (a host firewall modified out-of-band — it/segment/drift, T1686). Returns {drifts:[{point,drifted,expected,attested,orphan,backend,drift_kind}]} (backend = the backend that served that point's read-back; drift_kind as on GET /v1/segment, 2026-10-03m); a drift pages via detect.SegmentRule
POST /v1/segment/simulate read the Decide ground-truth oracle — what the current policy WOULD do to a concrete {from,to,proto,port} flow (default-DENY zero trust; priority desc, deny-wins). The operator's "why was this blocked?" answer
GET /v1/fedlearn read federated learning (C37): the training-plane board Report — per-model version/method/rounds/failures, per-participant update+poison counts, last publish, (budgeted models) the newest epsilon_spent/epsilon_budget receipt, and the content channel's distribution rows (each region's newest artifact ack: version/applied/mismatch/err) + mismatches counter — folded from the tenant's it/fedlearn/* RECEIPT window (?since/?until; no event ever carries weights)
GET /v1/fedlearn/model read the model registry — each declared model's spec, roster, and current aggregated global weights (the operator owns the ARTIFACT; participants' data never crossed a boundary), plus etag, the compare-and-swap token the setter below requires back as if_match. Unlike the segmentation etag this covers the AUTHORED half ONLY (spec + roster), so it is stable across training: a round opening, a submitted update, an aggregate or a publish does NOT move it, and therefore cannot invalidate an operator's in-flight edit. A budgeted model carries the accountant's dp block: {epsilon_budget, delta, epsilon_spent, rounds_charged, exhausted, mechanism} (exhausted = cannot fund ONE MORE round — can be true while spent < budget; mechanism = "discrete-gaussian", the DPO-facing receipt that the noise is exact integer sampling, no Mironov float leakage)
POST /v1/fedlearn/model manage-detections declare/replace a federated model: {spec:{id,dim,clip_norm,min_participants,secure_agg?,recovery_threshold?,signed_echoes?,trim_frac?,noise_sigma?,epsilon_budget?,delta?}, roster:[{id,region?,pubkey?,sign_key?}], if_match?} (fail-closed: quorum ≥ 2, secure↔robust mutual exclusion, X25519 keys required in secure mode, Ed25519 sign_key on every member required with signed_echoes [which itself requires recovery_threshold]; an ε budget REQUIRES noise_sigma > 0 and delta ∈ (0,1) — a budget over a noise-free release is a refused contradiction). if_match is the etag this edit was computed against: a mismatch is 409 and NOTHING is written; absent means "I expect nothing declared", so a first declaration needs none and a blind re-declare of an existing model is 409, not an overwrite. The response carries the stored view plus the NEW etag, so edits chain without a re-read. A same-dim re-declare keeps the trained weights; NO re-declare resets the ε ledger (privacy spent is spent). A re-declare that actually CHANGED the declaration also invalidates any in-flight round — the submitted updates were trained and masked against the roster/spec being replaced — reported back as round_invalidated (the round number) + updates_discarded; the round closes, its number is still burned, and the regions resubmit into a fresh one. An IDENTICAL re-declare moves nothing and leaves a live round alone
POST /v1/fedlearn/round manage-detections open the model's next training round (one at a time — mask epochs must not interleave). A model with recovery_threshold opens into the ADVERTISE phase of the dropout-recovery protocol. A budgeted model is GATED here fail-closed: a round the ε ledger cannot fund is 409 Conflict (ErrBudgetExhausted) BEFORE anything opens or is released; plaintext rounds charge the durable ledger at open, secure rounds at the successful unmask
GET /v1/fedlearn/round ingest participant leg (recoverable rounds) — ?model=&participant=: the round's phase + the advertised set's ephemeral keys + the Shamir bundles SEALED to this participant (ciphertext — the coordinator cannot read them) + (confirm/unmask phases) the published included/dropped partition its reveals must follow + (signed-echo models) the collected echoes, which the participant verifies (fedlearn.VerifyEchoQuorum) against the transcript of its OWN view before revealing anything
POST /v1/fedlearn/advertise ingest participant leg (recoverable rounds, phase 1) — the per-round ephemeral X25519 public key + one sealed share bundle per roster peer ({model,round,participant,eph_pub,bundles}). Roster/phase/round-gated; must cover EVERY peer; auto-advances to submit when the last member advertises
POST /v1/fedlearn/update ingest the participant leg — one region's round contribution ({model,round,base_version,participant,delta+samples \| masked}), submitted with the regional cell's ingest key. Roster-ENFORCED server-side (a non-roster participant is 400); stale rounds/versions and wrong-mode payloads are refused. Idempotent per participant. In a recoverable round the submitter must have ADVERTISED, and the round auto-advances to unmask when every advertiser has submitted
POST /v1/fedlearn/confirm ingest participant leg (signed-echo rounds, the CONFIRM phase) — an included survivor's Ed25519 attestation of the published partition ({model,round,participant,sig} — sig over the canonical epoch-bound PartitionTranscript of the survivor's OWN view). Verified at the boundary against the roster sign_key and the coordinator's partition (a forged or different-view echo is 400 — the equivocation tell); dropped members cannot confirm; idempotent. When every included survivor has confirmed, the round auto-advances to unmask (confirmed receipt). Closes the malicious-coordinator equivocation attack on the Bonawitz unmask (spec.signed_echoes)
POST /v1/fedlearn/evidence ingest the equivocation-evidence REPORT leg (signed-echo models) — a double-signed transcript pair ({model,round,accused[,reporter],a,b}, each side the FULL partition view {eph_pubs,included,dropped,sig}, never bare digests). Re-verified SERVER-side (fedlearn.VerifyEquivocation): both transcripts recomputed (domain-tagged, epoch-bound — cross-round/cross-model splices can never frame an honest member) + both Ed25519 signatures checked against the accused's roster sign_key. Trust from the math, never the reporter; an unverifiable accusation is a named 400 that lands NOTHING. On proof: the Critical it/fedlearn/equivocation finding (ATLAS AML.T0035) carrying the verified pair verbatim, paged by detect.FedLearnRule — the round was equivocated (the accused is a corrupt double-signer OR the victim a corrupt coordinator showed two partitions; both need an analyst)
POST /v1/fedlearn/recover ingest participant leg (recoverable rounds, phase 3) — a SURVIVOR's Shamir-fragment reveal ({model,round,participant,b_shares,sk_shares}). The never-both partition rule is enforced at this boundary: self-mask shares only for INCLUDED members, ephemeral-key shares only for DROPPED members — a crossing reveal is refused whole. On a signed-echo model the revealer must itself have CONFIRMED (reveal set ⊆ echo set)
POST /v1/fedlearn/advance manage-detections the operator lever past a stalled recoverable-round phase (advertise→submit excludes silent members; submit→confirm-or-unmask fixes the included/dropped partition + lands the recovering receipt; confirm→unmask requires ≥ threshold signed echoes and lands the confirmed receipt). Refuses an advance that could never aggregate (below quorum/threshold). No-op on the happy path (auto-advances cover it)
POST /v1/fedlearn/aggregate manage-detections close the open round through the engine: poison detection + FedAvg/trimmed-mean (plaintext) or the mask-cancelling sum (secure). A rejected update lands a High it/fedlearn/poison (ATLAS AML.T0020) that pages via detect.FedLearnRule. A failed round (quorum, incomplete secure set) is 422 + discarded — fail closed. A RECOVERABLE round finalizes from the unmask phase via fedlearn.RecoverSecure (method secure-fedavg-recovered, dropped count in the result); a below-threshold reveal set is 422 + RETRYABLE (the round stays open), a wrong-phase aggregate refuses without closing
POST /v1/fedlearn/publish act {model[,holdout]} — gated on evidence (2026-09-19g, audit B13-D9): with a holdout (labelled {features,label} rows, ≤ 10,000, brain-side, never a region's data) the candidate is measured against the PUBLISHED model on the same rows — a regression is 409 publish_regressed carrying both measurements, a pass lets the conductor decide; with NO holdout the publish is HELD for a second person (held_id in the 200, deployed:false). Then: push the aggregated global model through the conductor gate (DeployDetection — a trained model IS a compiled detection artifact; BlastRadius = roster size, reversible) to the Publisher seam — by DEFAULT the platform's own durable content channel (§C13; the artifact persists as an it/platform/fedlearn-artifact snapshot regions pull below). Returns {deployed, decision, receipt}; held/denied ships nothing
POST /v1/fedlearn/publish/{id}/approve approve + step-up release a HELD publish (2026-09-19g): the parked ARTIFACT ships — the version the requester saw, never what the model has aggregated to since. Four-eyes (the requester is refused, 403); another tenant's id is 404; a same-or-newer version published meanwhile is 409 (releasing it would roll participants back). Memory-only: a restart drops the hold and the operator re-requests.
GET /v1/fedlearn/artifact ingest participant leg (the §C13 content channel PULL) — ?model=&participant=[&version=] → the conductor-gated artifact (weights + version + sha256 content-address digest) for a roster member. version absent/0 = latest; the PREVIOUS published version stays served (the rollback window); anything else a named refusal. The region verifies with fedlearn.VerifyArtifact before loading
POST /v1/fedlearn/artifact/ack ingest participant leg (the attestation) — {model,participant,version,digest,applied[,err]}: the region ECHOES the digest it verified. Match+applied = Info distribution receipt; apply failure / stale version = Medium (operational, never pages); a digest that differs from the GATED artifact for a served version = the High it/fedlearn/artifact mismatch finding (ATLAS AML.T0010.003 — a tampered/substituted model in the distribution channel**)** paged by detect.FedLearnRule. The ack is still 200 — the finding is the outcome
POST /v1/session open the ONE front door (D2 session plane · SSO) — exchange the credential ONCE for a server-side session (web = HttpOnly·Secure·SameSite=Strict cookie + CSRF token; native = a keystore/StrongBox-bound session). 30-min sliding idle · 12-h absolute lifetime. Body is {key} or {passkey_challenge} (an approved MFA challenge, traded for the session) — never both: two factors in one request is ambiguity about which was proven, so it is a 400 exactly as neither is
GET /v1/session session re-bootstrap on reload/relaunch — who am I, entitlements, expiry (the "still signed in" check; no re-prompt); locale + locale_source since 2026-09-18p
DELETE /v1/session session ends this session server-side in one call — and with it every TAB riding it (MFA · Wallet · Messages). ⛔ Not the person's other devices: that is DELETE /v1/me/sessions below, and reading "everywhere" across those two axes is what filed a wholly-unbuilt feature as half-built
GET /v1/me/sessions any authenticated the subject's own session inventory — "where am I signed in" (row P wave 1, api/mesessions.go). Rows carry a ref, current, via (key·passkey·sso·invitation), the mint-time ip/agent, credential_id, and both deadlines. ⛔⛔ ref is a second random token that authenticates NOTHING — the session id IS the cookie credential, so returning ids would hand a caller working keys to its own other logins. Scope is (tenant, audience, who); has_current_session is REPORTED (a bearer/SVID caller appears in none of its own rows), and not_covered names the login planes this read cannot see, with the reason for each
DELETE /v1/me/sessions/{ref} any authenticated end ONE of my logins. Unknown, expired and somebody-else's ref all answer the SAME 404 — splitting them would make the handle space an oracle. Revoking the session you are holding clears the cookie
POST /v1/me/lockdown any authenticated (no step-up) one-tap lockdown (MFA/Buoy audit R7, 2026-10-20x): ends EVERY session of the caller including this one (cookie cleared), freezes account recovery for 72 h (a new request is refused behind the identical anonymous 202; pending requests naming the caller are denied resolved_by: account-lockdown; an approval answers 409 access.recovery.resolve.frozen, no acknowledgement overrides it), files it/platform/account-lockdown locked (High), and notifies by push (Ref{kind: account, state: locked}) and proven-address mail (no link). {locked_down, sessions_ended, recovery_frozen, frozen_until, recoveries_denied, still_signed_in:false}
GET /v1/me/lockdown any authenticated {recovery_frozen, frozen_until}
DELETE /v1/me/lockdown any authenticated + step-up lifts the freeze early (re-opens recovery, so it takes a proven factor); 404 me.lockdown.not_active when none is active
GET /v1/me/exposure any authenticated + exposure@self "has MY address turned up in a breach?" (row P wave 1 family B, api/myexposure.go, 2026-08-20l). ⛔⛔ IT TAKES NO PARAMETERS AND MUST NEVER TAKE ONE — the address comes from assembly.ProvenAddress (a redeemed SELF-SIGNUP invitation whose Email == Who, the one flow whose token is disclosed to nobody) and from nowhere else, which is what keeps "tell me about MY address" answerable and "tell me about THAT address" unanswerable. ⛔⛔ p.Who IS NOT PROOF OF CONTROL and gating on it is the bug: an operator invitation may set who to any address, the OEM credential door takes it verbatim, and issueInvitation RETURNS the raw link to its issuer even when the mail was accepted — so an operator-issued or recovery-issued invitation proves the OPERATOR read a mailbox. Returns {status, breaches[], complete, corpus_breaches, corpus_identifiers, corpus_updated, dropped_at_ingest}. ⛔ status: "unavailable" means NO CORPUS IS LOADED — nothing was compared, and it is NOT an empty result set; there is deliberately no clean status, and complete:false says even a checked empty answer came from a truncated corpus. TWO independent gates, both required: the entitlement (commercial — does this deployment sell it) and proven control (a disclosure boundary), so a packaging change can never move a privacy control. 403 when either refuses, naming the CREDENTIAL and never the address
GET /v1/me/report any authenticated + self-service tenant a person's own PERIOD REPORT (row Q §10.10, api/mereport.go, 2026-08-22c) — {period, machines[], detections{}, exposure{}, not_covered[]} over one bounded window (?since/?until, default 30 days; an inverted period is 400). ⛔⛔ IT REPORTS WHAT ARRIVED BEFORE WHAT WAS FOUND, because an empty findings list has THREE causes that render identically — a watched quiet month · a machine that sent nothing · no machine ever enrolled — and the one that means you are not protected is the one the reader most needs. Each machine carries a measured reading: sent · sent-nothing · sent-out-of-window. ⛔⛔ THE LAST OF THOSE EXISTS BECAUSE TWO CLOCKS MEET HERE: a row is placed in the period by the time the MACHINE declared (decoders prefer the frame's own timestamp) while an alert is placed by the BRAIN's — so an agent that was offline and caught up produces an in-period detection out of out-of-period rows, and the first draft answered sent-nothing for the very machine whose detection it printed. ⭐⭐ period and exposure are different TENSES and are never merged — the breach answer is point-in-time (as_of), never a period delta, and its five states include not-sold and no-proven-address (both exposure gates are RE-APPLIED here, never inherited). ⛔ No score, no grade, no green tick. 403 in a managed tenant, routing the person to POST /v1/me/rights
POST /v1/platform/exposure/corpus OEM platform admin (canManageExposureCorpus) load the node-wide breach corpus: {breaches:[{name,title,domain,occurred,disclosed,accounts,classes[],verified,source}], identifiers:[{breach,kind,hashes[]|values[]}]} → {accepted,rejected,dropped,corpus_*}. hashes (lowercase-hex SHA-256 over the normalised identifier, what a licensed corpus ships) is preferred over values. ⛔⛔ Platform-gated, not tenant-admin-gated — ONE dataset with no tenant dimension, so a tenant admin writing here writes into every other tenant's answers. ⛔ Identifiers for a breach whose metadata was never registered are REFUSED (400): a match that cannot be NAMED is alarm without recourse. ⚠ Malformed rows are COUNTED, never fatal — a million-row corpus must load with three bad rows in it, and a load that accepted everything and then matches nobody is what a normalisation disagreement looks like
POST /v1/platform/exposure/watch OEM platform admin (canManageExposureCorpus) run one breach-notification pass (assembly.RunExposureWatch, 2026-08-20n) → {subjects, entitled, seeded, notified, new_records, corpus_unavailable}. ⛔⛔ A DELIBERATE ACT, NOT A SIDE EFFECT OF THE LOAD ABOVE — firing it from the corpus-load handler was the obvious wiring and is wrong twice: the load would block for as long as the estate takes to walk, and a bulk outbound mail run must be something a person chose at a moment they chose (an operator loading a corpus at 03:00 to test it has not asked to mail every customer). The autonomous form is EVENTLAKE_EXPOSURE_WATCH, off by default for the same reason. ⛔⛔ The sweep walks SUBJECTS, never the corpus — walking the corpus needs a reverse index from other people's stolen identifiers into our customer list, and walking subjects reuses ProvenAddress, so a person who cannot be ANSWERED can never be NOTIFIED. ⛔ It checks the ENTITLEMENT too (read from the tenant's durable credential records): a sweep that skipped it would mail every consumer about a capability the line has not sold them. ⭐ A subject's FIRST pass SEEDS silently — the first corpus load would otherwise mail the estate about breaches from years ago, which is onboarding content the exposure screen already carries, not an alert. ⚠ All six counts are reported because "notified 0" is true of a healthy pass, of a node with no corpus, and of a node where nobody holds the grant
GET /v1/platform/exposure OEM platform admin what the corpus holds — counts only. {status, corpus_breaches, corpus_identifiers, complete, dropped_at_ingest, corpus_updated}. There is no read door over corpus CONTENT anywhere in the platform, for an operator any more than for a subject; the READ takes the platform gate too, unlike intel's, because this is a record of other people's stolen data held by us as CONTROLLER rather than a blocklist whose publication IS the product
DELETE /v1/me/sessions any authenticated sign out everywhere (mine) — ends every other session of this identity and answers {revoked, kept_current, still_signed_in}. The DEFAULT keeps the session you are on: the act this exists for is "I see a login I do not recognise", and signing the person out of the screen they pressed it on teaches them not to press it. ?include_current=true is the deliberate opposite. Every non-empty sweep is audited (authentication/session/revoke, carrying the COUNT and whether the actor signed itself out)
POST /v1/session/stepup session approve a sensitive action (5-min freshness for authStepUp routes) — transaction AUTHORIZATION, never a second login. Takes {passkey_challenge}, or {key} only while the operator has no passkey bound: once one is, the key-only step-up is REFUSED with 403 step_up_requires_passkey (a stronger factor a caller may silently decline is not a stronger factor; per-principal, so a rollout locks nobody out mid-flight)
POST /v1/session/passkey/begin open the passkey LOGIN door (api/passkeylogin.go) — {tenant, subject} → {challenge_id, number, expires, required_factors}: raise the MFA number-match on the subject's enrolled device. Unauthenticated by design (it IS the login), so it rides the session-mint rate limiter and every refusal is the SAME 401 (unknown subject · no binding · revoked credential are indistinguishable — a specific message here is an account-enumeration oracle; the real reason goes to the denial telemetry). Fatigue cap → 429 + Retry-After
POST /v1/session/stepup/begin session raise the approval challenge for the session's OWN principal — {action} rides the challenge context so the PHONE shows what is being approved (a number-match with no stated act is a habit, not a decision). 409 when no passkey is bound
POST /v1/mfa/security-keys/register/begin self (mfa@self) + step-up the securitykey MFA factor (api/mfasecuritykey.go, 2026-10-01fk): open a WebAuthn registration bound to the SUBJECT, not a device — {ceremony_id, publicKey} (creation options; authenticatorAttachment: cross-platform is a browser HINT the server does not trust; userVerification: preferred — UV is recorded, never required). 503 mfa.security_key.not_configured without EVENTLAKE_WEBAUTHN_RP_ID; 409 mfa.security_key.full past 10 keys
POST /v1/mfa/security-keys/register/finish self + step-up {ceremony_id, label, credential} (PublicKeyCredential.toJSON()) → {id, label, alg, attestation_format, attestation_trust, added, clone_suspected}. Refuses a backup-eligible (synced) passkey (400 mfa.security_key.synced_refused), another subject's or a spent ceremony (400 mfa.security_key.ceremony_unknown), a credential any subject already holds (409 mfa.security_key.exists). attestation_trust is a record, never an authorization input
GET /v1/mfa/security-keys self {enabled, count, security_keys[]} — the subject's keys; clone_suspected is sticky once a counter failed to advance
DELETE /v1/mfa/security-keys/{id} self + step-up remove a key; another subject's id answers as an absent one (404 mfa.security_key.not_found). A challenge that recorded a proof from it can no longer be approved
GET /v1/mfa/challenges/{id}/securitykey self {challenge_id, publicKey} — the request options for navigator.credentials.get(); the challenge is DERIVED from the MFA challenge for decision approve (mfa.SecurityKeyChallenge), so no ceremony id. Another subject's challenge answers as an unknown one (404 mfa.challenge.not_found); 409 mfa.security_key.not_required when the challenge does not need the key
POST /v1/mfa/challenges/{id}/securitykey self {credential} (the assertion's toJSON()) → {id, securitykey_proven: true, status: "pending"}. RECORDS the key's APPROVE proof and approves nothing: the phone's POST …/resolve commits it, and re-checks the key is still live. A failed proof (phishing origin, other RP ID, no UP, another key) is 403 mfa.proof.failed, the WebAuthn reason going to the denial record only. GET /v1/mfa/challenges rows carry securitykey_proven when the key is required
GET /v1/operators/passkeys issue-credential (admin) list the tenant's operator passkey bindings, revoked rows included and flagged (a binding holds no secret); a Platform key sees the fleet
POST /v1/operators/passkeys issue-credential (admin) bind {subject, credential_id[, tenant]} — the MFA subject whose device approves ↔ an existing credential whose principal it presents. A binding carries NO authority of its own: role/tenant/tier resolve from that credential at mint time, so it can never escalate, and revoking the credential closes this door in the same act. Tenant + bound_by FORCED from the caller; never self-service (the device holder may not bind their own). 404 unknown/cross-tenant credential · 409 subject has no enrolled device, or already has a live binding. Durable via it/platform/operator-passkey (D22)
DELETE /v1/operators/passkeys/{id} issue-credential (admin) revoke a binding — the device stops being a login door immediately; the record stays listed as revoked (tenant-guarded)
GET /v1/sso/idps read enterprise SSO-inbound (review §7a W41, api/sso.go): list the registered upstream IdP trusts (client secret REDACTED); a Platform key sees the fleet
POST /v1/sso/idps manage-sso (admin) register (or replace by id) an upstream OIDC IdP trust — {id, name, issuer, client_id, client_secret, redirect_uri, scopes?, groups_claim?, role_mappings{group→admin\|analyst\|auditor}, default_role?, allowed_domains?, require_provisioned?, enforce_sso?}; unpinned endpoints filled from the issuer's discovery document, then fail-closed ValidateIdP (https-only, named 400s); tenant FORCED from the principal. Durable via it/platform/sso-idp (D22)
DELETE /v1/sso/idps/{id} manage-sso sever an IdP trust (tenant-guarded)
GET /v1/sso/login?idp=… open begin the inbound login: mints the single-use state + nonce + PKCE S256 and 302s the browser to the IdP (unauthenticated by design — it IS the login)
GET /v1/sso/callback open the IdP redirects back: the ID token is verified SERVER-side (JWKS sig RS256/ES256/EdDSA · iss · aud · exp · nonce; single-use state = the login-CSRF defense), the operator authorized (domain allowlist · deactivated-leaver refusal · require_provisioned no-JIT gate · group→role mapping, HUMAN roles only) → success mints the SAME D2 cookie session a local login would (one session plane, two doors). Every success/refusal is an it/authentication event — the console's own front door feeds the detection substrate
GET /v1/sso/directory read the console's SCIM-fed operator-directory read (2026-07-20): redacted identity/lifecycle rows {tenant, id, user_name, display, emails, active, groups, external_id, created, modified} with group membership folded from the Group resources; tenant-scoped (Platform reads the fleet). Exists so no operator key ever holds the provisioning credential — /scim/v2 itself stays SCIM-token-authed
GET /v1/sso/scim-tokens read list SCIM provisioning tokens (ids + names, never the secret)
POST /v1/sso/scim-tokens manage-sso mint a SCIM bearer token for the caller's tenant (raw token returned ONCE; the platform keeps a hash). Durable via it/platform/scim-token
DELETE /v1/sso/scim-tokens/{id} manage-sso revoke a provisioning token (tenant-guarded)
/scim/v2/* SCIM token the RFC 7643/7644 provisioning surface (clean-room conduit/scim): Users/Groups CRUD + PATCH (add/replace/remove, members ops), filter=userName eq "…", pagination, ServiceProviderConfig/ResourceTypes/Schemas discovery; authenticated by the SCIM bearer token → THAT token's tenant only. Every operator/group mutation lands durably as it/identity/operator(-group) — the joiner-mover-leaver lifecycle survives restart; a deactivated (leaver) operator is refused at SSO even with a valid upstream token
POST /v1/enroll/tokens admin mint a one-time enrollment join token (tenant forced from the operator). kind (2026-08-24f) says WHAT is joining, the same two named values and the SAME parser as the person's own door: agent (a machine running the endpoint agent — the SVID carries RoleSensor, so it may register at POST /v1/sensor/register and ship telemetry about itself) or app (a device somebody reads their own account on). ⛔⛔ Before this field existed the door minted no kind at all, so roleForDeviceKind("") resolved to no role and every machine an operator enrolled — the MANAGED-tenant path, and the one POST /v1/me/devices/enroll-token's 403 tells an employee to use — authenticated perfectly and was refused at every canAgent route. ⛔ Absent means app, deliberately: defaulting an already-shipped door to the agent kind would widen every credential every existing caller mints without the caller asking, and it keeps the default enrolment's signed bytes byte-identical (Claims.Kind is omitempty inside the SVID signedBytes() marshals whole). ⛔ An unrecognised kind is a 400 naming both values, never a silent fallback. ⛔ No self-service gate here (unlike the person's door, where the agent kind is a widening): the caller is an admin who already holds everything RoleSensor resolves to, so the credential is strictly narrower than the principal that minted it. Returns {token, source_id, key_id, tenant, kind, expires_in} — the kind is echoed because a ceremony that cannot say which credential it minted cannot tell the operator whether the host will be able to register. ttl_seconds (2026-09-19az) is optional (absent/0 ⇒ 15 min) and CAPPED at 86400 (24h, enroll.MaxJoinTokenTTL): a larger value is a 400 naming the field and the ceiling, refused on the integer BEFORE the Duration multiply (above ~9.2e9 s that multiply wrapped, so the door used to answer 201 with a token already expired — or, just past 2^64 ns, one that lived a minute). Refused, never clamped; the same ceiling is enforced inside the enrolment authority for every door.
GET /v1/enroll/svids read the enrolled-identity registry (source · key · window · server-computed status active/expiring/expired/revoked), tenant-forced
DELETE /v1/enroll/svids/{id} admin revoke an enrolled identity by key id (url-encoded) — refused at the auth gate + barred from renewal thereafter; tenant forced
POST /v1/enroll open bootstrap: present a join token + public key → a short-lived signed SVID
GET /v1/enroll/challenge open get a single-use proof-of-possession nonce for renewal
POST /v1/enroll/renew open rotate: present the SVID + a signature over the nonce → a fresh SVID
POST /v1/mcp read (+ per-tool) the MCP server (§2.5.9 Posture A, BUILT 2026-07-11): a clean-room stdlib Model Context Protocol endpoint — one stateless streamable-HTTP door carrying single JSON-RPC 2.0 messages (initialize w/ version negotiation 2024-11-05·2025-03-26·2025-06-18 · ping · tools/list · tools/call · resources/list · resources/read; notifications → 202; batches refused; unsupported MCP-Protocol-Version header → 400), so ANY agent host (Claude Desktop/Code, an IDE, a customer agent platform) becomes an operator surface with zero new UI. SAME auth → Principal; per-tool RBAC inside the dispatcher (tools/list is role-FILTERED discovery; an out-of-role call → isError result + a PEPAPIRBAC denial). The §5.1 tool table: platform_ask · platform_query (ViewPage — masked/audited/cursored) · platform_investigate · aether_cases_list/get/annotate (annotate = canAct, comment marked AI-channel) · platform_coverage · platform_posture (ctem/easm/agentmesh) · platform_propose_action (canAct) · platform_report_run (renamed from aether_* by 2026-10-01ad, §13 Kind C: tools/list advertises ONLY platform_*; the ten retired aether_* spellings stay callable on tools/call for the alias window, telemetry naming the current tool with the sent spelling as tool_alias); resources = the detection-as-code corpus (aether://detections/<id>) + the docs corpus when a static root is configured (aether://docs/<name>.md, traversal-proof). Non-negotiable 1: no MCP tool actuates — platform_propose_action terminates at the conductor gate via actions.Propose (even a chain-allow is DOWNGRADED to a hold; the outcome is only ever awaiting-approval/denied; a human releases via POST /v1/actions/{id}/approve). Non-negotiable 2: every tools/call + resources/read lands one canonical it/ai/tool-call (assembly.RecordMCPToolCall — subject=caller, app eventlake-mcp (written as aether-mcp before 2026-10-02r; sealed rows keep it, read forever), redacted+capped arguments + sha256, allowed flag, latency), the SAME stream decode/genai normalizes — aispm/agentsec watch the agents operating this SOC. GET/DELETE → 405 (no server stream, no session state)

4.2 Query parameters, interchange format, and pagination

Query parameters (/v1/events, /v1/aggregate): category, type, activity, domain, entity, technique, text, min_severity (info|low|medium|high|critical), since/until (RFC 3339), limit, order (asc|desc), and field (the aggregate group-by).

OCSF interchange export (/v1/events, /v1/alerts, /v1/cases): add ?format=ocsf to receive each event in the Open Cybersecurity Schema Framework (OCSF 1.1.0 — the vendor-neutral convergence schema AWS Security Lake and the converging vendors speak) instead of the canonical envelope. Events are serialized through ocsf.FromEvent after role-masking and tenant-forcing — pure serialization of the same ViewPage rows, so masking, RBAC and the cursored page all apply unchanged. The response carries schema: "ocsf/1.1.0", and each object's exact canonical class rides under unmapped so a consumer's round-trip is lossless.

A data-lake loader usually wants a stream of objects rather than a paged envelope, so ?format=ocsf also takes &ocsf_style=: array returns a bare JSON array (application/json), ndjson one object per line (application/x-ndjson). For those two shapes the body carries data only, so the page cursor moves to the X-Next-Cursor response header (with X-OCSF-Schema: ocsf/1.1.0) — a full export follows the header exactly as the envelope follows next_cursor. ocsf_style=envelope is the default; ocsf_style is rejected (400) without format=ocsf or with an unknown value.

Duration fields — a count in a unit is refused on the COUNT (2026-09-19az · 19bb · 19dw). Every body or query field that carries a duration as a whole number of a unit — *_seconds, window_minutes, ttl_hours, stale_days, TOTP period — is checked as the integer the caller sent, before it is multiplied into a duration. The multiply is 64-bit and wraps silently (past ~9.2e9 s, ~1.5e8 min, ~2.56e6 h, ~106751 days): a wrapped count lands negative, or on a small positive value that slips under any ceiling checked afterwards. So: a negative count is 400 (it used to be read as 0 at most doors — "default", "no expiry", "off" — which is not what a caller sending -1 asked for); a count past the field's policy ceiling, where one exists, is 400 naming the limit; a count past the longest representable duration is 400. Refused, never clamped. 0 keeps each door's own meaning (a default, no expiry, open-ended, run-now-only). Ceilings reused, not invented: PAM ttl_seconds ≤ 28800 (assembly.MaxElevationTTL); invitation / lead-convert ttl_hours ≤ 336 (assembly.InviteMaxTTL); search throttle_seconds ≤ 86400 (assembly.MaxSearchThrottle — the throttle map's own sweep horizon, so a longer throttle was accepted and then under-enforced); TOTP period in [10, 120] (mfa.MinTOTPPeriod / MaxTOTPPeriod — the URI door already refused outside it; the body door used to clamp, enrolling an account whose codes never matched); join-token, RoE, reveal ceilings per 19az/19bb. Every other field is bounded only by representability. POST /v1/erasure/sweep additionally refuses a body that does not decode (a grace_seconds too large for an integer used to fail silently and sweep with grace 0); an empty body is still grace 0. One implementation: api.durationField / api.secondsField (conduit/api/secondsfield.go).

Two doors onto one resource enforce the same rules (2026-09-20g). Where a route accepts the same thing in two shapes — the scanned otpauth:// URI or the explicit body fields of POST /v1/mfa/totp — a rule the parsing door enforces is enforced on the other door too, or it is not enforced. The body form now refuses digits outside [6, 8] (mfa.MinTOTPDigits/MaxTOTPDigits) and a non-empty algorithm outside {SHA1, SHA256, SHA512} (mfa.TOTPAlgorithms(), case- and space-insensitive via mfa.CanonicalTOTPAlgorithm), exactly as mfa.ParseOTPAuth always refused them in a URI. They used to be clamped — 9 → 8, MD5 → SHA1 — which answered 201 for an account whose codes can never match the issuer's, discovered at the next sign-in. 0 / "" keep their meaning (the universal QR defaults 6 · 30 s · SHA-1) and the 201 echoes the CANONICAL spelling. ⚠ The clamp itself STAYS in mfa.TOTPParams.Normalize, which runs on replay of sealed history where a refusal is a node that will not boot: the split is about who can still act, not about which value is right. ⚠ When a uri IS supplied it wins and the body parameter fields are ignored rather than refused — the URI is the issuer's own statement, the body fields are the client's guess at it.

The error envelope, and the OPTIONAL code beside the sentence. Every refusal answers {"error": "<sentence>"} — prose written for the engineer reading it, and always the primary contract. A refusal a client must branch on rather than merely display also carries {"code": "<key>"} (api/json.go, writeCodedError). ⭐⭐ It exists because the alternative was already being built by hand: the licence plane alone produces four different 403s — renew · buy this capability · buy this line · install a signed document — whose only difference was their English, so the first console banner to need them would have substring-matched a sentence, and the first reword would have silently un-matched it. ⛔ The code is a JOIN KEY: functional, lower_snake, drawn from a closed set per plane, never branded, never the error text repeated. ⛔ It is ADDITIVE and omitted when empty — every shipped client decodes error and ignores what it does not know, so no existing caller changes behaviour. ⚠ It is not an authorization fact and must never carry one: a code says which refusal this is, never anything the caller could not already infer from having been refused — a code separating "no such tenant" from "wrong password" would rebuild the enumeration oracle the login plane deliberately answers with ONE sentence for four causes. The licence plane's closed set is license_lapsed · license_feature_absent · license_line_absent · license_unsigned (api/licensegate.go; the spelling follows the American license_* counters it joins, not the British EVENTLAKE_LICENCE_* variables it reports on).

⭐ The ENTITLEMENT plane is a separate closed set of one — entitlement_not_granted (api/entitlementgate.go, 2026-08-26) — and the prefix is the point, not an inconsistency. The four license_* codes all mean your TENANT does not hold this, with remedies a customer BUYS. This one means your KEY does not carry this, with a remedy an operator RE-ISSUES. A console banner grouping on the prefix must not fold them together, which is exactly the failure the four licence codes were split out of one English sentence to prevent. Its denials record at the api-entitlement PEP — deliberately neither api-license (a fact about the tenant, fixed by a purchase) nor api-rbac (a fact about the role, fixed by a role change): three remedies, three people, and a board that groups them under one activity can route none of them.

Pagination (D8.1). Every events-shaped read is paged: limit is clamped server-side to 1000 (an absent limit gets the maximum), and when more rows remain the envelope carries an opaque next_cursor — pass it back as ?cursor= to resume. The cursor anchors on the last row's stable sort position, not an offset, so rows appended mid-walk never duplicate or skip across pages; a malformed cursor is a 400, never a silent restart.

4.3 The gRPC service catalogue

Two services plus the standard health probe, on a separate listener (EVENTLAKE_GRPC_ADDR, off by default).

RPC Shape REST twin Gate
grpc.health.v1.Health/Check unary — unauthenticated; returns SERVING
eventlake.v1.Query/GetEvents unary GET /v1/events canRead
eventlake.v1.Query/GetAlerts · GetCases unary GET /v1/alerts · /v1/cases canRead; Category SERVER-FORCED
eventlake.v1.Query/Aggregate unary GET /v1/aggregate canRead; group-by field required → INVALID_ARGUMENT
eventlake.v1.Query/ListReports unary GET /v1/reports canRead; templates are tenant-agnostic metadata
eventlake.v1.Query/GetReport unary GET /v1/reports/{id} canRead; rendered via RenderReportForTenant, tenant forced
eventlake.v1.Query/TailEvents · TailAlerts · TailCases server-streaming — (live tail) canRead; Lossy subscription, per-event masking, ONE close-audit("stream", …)
eventlake.v1.Query/WatchEvents · WatchAlerts · WatchCases bidirectional — (re-narrowable tail) as the tails, plus a client→server command channel
eventlake.v1.Command/ProposeAction unary POST /v1/actions canAct; tenant/proposer/time server-controlled, never on the wire
eventlake.v1.Command/ListPending unary GET /v1/actions/pending canApprove; filtered to the principal's tenant
eventlake.v1.Command/ApproveAction unary POST /v1/actions/{id}/approve canApprove; another tenant's held action → NOT_FOUND
eventlake.v1.Command/TransitionCase · AssignCase · CommentCase · AddCaseTask · CompleteCaseTask unary POST /v1/cases/{id}/{transition,assign,comments,tasks,tasks/{index}/complete} the REST door's own gate (canAct, bridged); returns eventlake.v1.Case (caseView, transcoded)
eventlake.v1.Command/SetAlertStatus unary POST /v1/alerts/{id}/status the REST door's own gate (canAct, bridged; 2026-10-12gd); the alert is looked up in the credential's tenant only (another tenant's id is NOT_FOUND), the actor is the credential; an absent status is REST's 400, never open; returns REST's {alert, state} as google.protobuf.Struct. CLI: eventlake-ctl alerts status
eventlake.v1.Command/OpenCaseFromAlert · AttachAlertToCase unary POST /v1/alerts/{id}/case · POST /v1/cases/{id}/alerts the REST doors' own gate (canAct, bridged; 2026-10-15aq); the alert and the case are looked up in the credential's tenant through the need-to-know gate (another tenant's or a compartmented id is NOT_FOUND), the actor is the credential; idempotent (an alert already on an open case answers that case, opened:false; a re-attach is attached:false); a closed case is REST's 409 cases.case.closed; returns REST's {case, opened} / {case, attached} as google.protobuf.Struct. Wire: OpenCaseFromAlertRequest{id=1}, AttachAlertToCaseRequest{id=1, alert_id=2}. CLI: eventlake-ctl alerts open-case · alerts add-to-case -case
eventlake.v1.Command/RecordFeedback unary POST /v1/feedback the REST door's own gate (canAct, bridged); analyst/tenant forced
eventlake.v1.Command/EngageKillSwitch · DisengageKillSwitch unary POST · DELETE /v1/actions/kill canApprove + step-up (bridged, bearer posture); scope REQUIRED on the wire, authorized by the REST handler
eventlake.v1.Command/AllowlistTarget · RemoveAllowlistTarget unary POST · DELETE /v1/actions/allowlist canApprove + step-up (bridged, bearer posture); tenant and operator from the credential, reason required (2026-10-03a)
eventlake.v1.Command/SetApproverPolicy unary PUT /v1/actions/policy canApprove + step-up (bridged); REST's 202 (OFF awaiting a second admin) → OK with disable_requested_by
eventlake.v1.Command/SetApproverRoster unary PUT /v1/actions/approvers canApprove (bridged, no step-up: it names who is told, never who may decide); clear=true empties the roster, and no approvers without clear is REST's own 400
eventlake.v1.Command/SetFairnessSources unary PUT /v1/vision/fairness/sources canApprove + step-up (bridged); clear=true empties the list (every source counts again), no sources without clear is REST's own 400
eventlake.v1.Command/ProposeHistoryRederive unary POST /v1/history/rederive canApprove (bridged); files an ALWAYS-held rederive-history request — released through ApproveAction; nothing to correct is REST's 409
eventlake.v1.Command/RegisterCompartment · GrantCompartment · RevokeCompartment unary POST /v1/compartments{,/grant,/revoke} canManageCompartments + step-up (bridged)
eventlake.v1.Command/RequestCompartmentRead · ApproveCompartmentRead · ExecuteCompartmentRead unary POST /v1/compartments/requests{,/{id}/approve,/{id}/execute} canRead · canApprove + step-up · canRead (bridged); the spine's two-person rules refuse on this door too
eventlake.v1.Command/StoreSecret · CheckoutSecret unary POST /v1/pam/secrets{,/{id}/checkout} canManageVault · canAct (bridged); the value is returned by the checkout only, and only with ownership or a covering lease
eventlake.v1.Command/RequestElevation · ApproveElevation · RevokeElevation unary POST /v1/pam/elevations{,/{id}/approve} · DELETE /v1/pam/elevations/{id} canAct (conductor-gated; * HELD) · canApprove + step-up, self-approval refused · canApprove + step-up (bridged)
eventlake.v1.Command/OpenSession · OpenProxySession · ApproveSession · ApproveProxySession · TerminateSession unary POST /v1/pam/sessions{,/proxy,/{id}/approve,/proxy/{id}/approve,/{id}/terminate} canAct · canAct · canApprove + step-up ×2 · canAct + step-up (bridged); holds keyed by transport; the connect token is served once, at the proxy open
eventlake.v1.Command/SetSegmentPolicy · SetSegmentEndpoints unary POST /v1/segment/{policy,endpoints} canManageDetections (bridged); if_match compare-and-swap, a stale or absent token against a declared record is FAILED_PRECONDITION
eventlake.v1.Command/DistributeSegmentPolicy · AttestSegment · SimulateSegmentFlow unary POST /v1/segment/{distribute,attest,simulate} canAct (conductor-gated wave) · canAct · canRead (bridged)

Message shapes (field numbers pinned in grpc.go / grpc_command.go): AggregateRequest = the shared query filters (fields 1–13, identical to GetEventsRequest) plus field 14 = the group-by field; AggregateResponse{ string field = 1; repeated Bucket buckets = 2 }, Bucket{ string key = 1; int32 count = 2 }, sorted count-desc/key-asc. ReportInfo{ id, title, description, panel_count }; RenderedReport{ id, title, description, tenant, generated_at, panels }, RenderedPanel{ title, description, viz, group_by, rows, buckets, value, band, severity, limit } (limit=10, added 2026-09-13ay: when set, the rows are a PAGE of the match set, not its size; a single-stat value is always a full count). ProposeActionRequest{ kind=1, target=2, reason=3, case_id=4, alert_id=5, source_rule=6, confidence=7 (double), severity=8, blast_radius=9, reversible=10 (bool), crown_jewel=11, techniques=12 } (kind + target required); Outcome{ request_id, kind, target, status, actuator, reason, time } with status ∈ {executed, awaiting-approval, denied, …}; ActionRequest{ id, kind, target, tenant, reason, case_id, alert_id, source_rule, confidence, severity, blast_radius, reversible, crown_jewel, techniques, proposed_by, time, approved, approver }. Case writes (2026-09-26yu): TransitionCaseRequest{ id=1, status=2, note=3 } · AssignCaseRequest{ id=1, assignee=2 } · CommentCaseRequest{ id=1, text=2 } · AddCaseTaskRequest{ id=1, text=2, assignee=3 } · CompleteCaseTaskRequest{ id=1, index=2 (int32) } → Case{ id=1, title=2, severity=3, status=4, priority=5, assignee=6, opened_at=7, updated_at=8, alert_ids=9, entities=10, techniques=11, tasks=12 (CaseTask{ index, text, assignee, done, auto }), timeline=13 (CaseEntry{ time, actor, text, auto }), comments=14 (CaseComment{ time, author, text, ai }), allowed_transitions=15, sla=16 (CaseSLA{ ack_due_at, resolve_due_at, acked_at, ack_breached_at, resolve_breached_at, resolve_leg_from=6 }) }; RecordFeedbackRequest{ rule=1, target=2, alert_id=3, case_id=4, disposition=5, note=6 } → RecordFeedbackResponse{ recorded=1 (bool), disposition=2 }. Pinned in grpc_bridge.go. Alert triage (2026-10-12gd, pinned in grpc_bridge_alerts.go): SetAlertStatusRequest{ id=1, status=2, disposition=3, reason=4 } → google.protobuf.Struct ({alert, state{status, disposition, reason, by, at}}, REST's JSON verbatim). With it every auth-gated REST mutation has a gRPC twin again: parity 404 of 404, and /v1/alerts/ joins the fully-twinned prefixes. Governance writes (2026-09-28rc, pinned in grpc_bridge_governance.go): EngageKillSwitchRequest{ scope=1 } · DisengageKillSwitchRequest{ scope=1 } → KillSwitchState{ engaged=1 (bool), scope=2 }; AllowlistTargetRequest{ target=1, reason=2 } · RemoveAllowlistTargetRequest{ target=1, reason=2 } → AllowlistChange{ target=1, allowlisted=2 (bool), applied=3 (bool) }; SetApproverPolicyRequest{ optional bool require_second_approver=1 } → ApproverPolicy{ require_second_approver=1, disable_requested_by=2, disable_requested_at=3 }; SetApproverRosterRequest{ repeated approvers=1, repeated escalation=2, escalate_after_seconds=3 (int64), clear=4 (bool) } → ApproverRoster{ repeated approvers=1, repeated escalation=2, escalate_after_seconds=3, updated_by=4, updated_at=5, repeated fallback=6 }; RegisterCompartmentRequest{ label=1, two_person=2 (bool) } → CompartmentPolicy{ label=1, two_person=2 }; GrantCompartmentRequest{ who=1, label=2 } → CompartmentGrant{ who=1, label=2, granted_by=3 }; RevokeCompartmentRequest{ who=1, label=2 } → RevokeCompartmentResponse{ revoked=1, who=2, label=3 }; RequestCompartmentReadRequest{ label=1, entity=2, since=3, until=4, justification=5 } and ApproveCompartmentReadRequest{ id=1 } → CompartmentRead{ id=1, who=2, label=3, entity=4, since=5, until=6, justification=7, status=8, approver=9, created=10, approved=11 }; ExecuteCompartmentReadRequest{ id=1 } → GetEventsResponse{ events=1, count=2 } (masked, as REST). PAM writes (2026-09-29ug, pinned in grpc_bridge_pam.go): StoreSecretRequest{ name=1, kind=2, owner=3, scope=4, value=5 } and CheckoutSecretRequest{ id=1 } → Secret{ id=1, name=2, kind=3, owner=4, scope=5, created_at=6, value=7 (checkout only) }; RequestElevationRequest{ scope=1, ttl_seconds=2 (int64), reason=3 } and ApproveElevationRequest{ id=1 } → ElevationOutcome{ status=1, request_id=2, decision=3, granted=4 (bool), lease=5 (Lease{ id, principal, scope, tenant, granted_at, expires_at, reason, approver }) }; RevokeElevationRequest{ id=1 } → RevokeElevationResponse{ revoked=1 }; OpenSessionRequest{ target=1, protocol=2, scope=3, reason=4 }, OpenProxySessionRequest{ …1–4, username=5, secret_id=6, proxy_addr=7 }, ApproveSessionRequest{ id=1 }, ApproveProxySessionRequest{ id=1 } → SessionOutcome{ status=1, mode=2, request_id=3, decision=4, session=5 (PAMSession), ticket=6 (ProxyTicket — no token field), connect_token=7 }; TerminateSessionRequest{ id=1, reason=2 } → TerminateSessionResponse{ terminated=1, session=2 }; PAMSession{ id=1 … last_hash=18 } (fields in pam.Session order; chunks/bytes/last_seq int64). REST's 201/202/200 (granted · held · recorded refusal) are all OK; status says which. Segmentation writes (2026-09-30kd, pinned in grpc_bridge_segment.go): SetSegmentPolicyRequest{ intents=1 (repeated Intent), if_match=2 } → SegmentPolicy{ tenant=1, revision=2 (int64), intents=3, etag=4 }; Intent{ id=1, description=2, from=3, to=4 (Selector), ports=5 (repeated PortRange), action=6, priority=7 (int64) }; Selector{ identity=1, labels=2 (map<string,string>), cidr=3, any=4 (bool) }; PortRange{ proto=1, from=2, to=3 (int64) }; SetSegmentEndpointsRequest{ endpoints=1 (repeated Workload), points=2 (repeated EnforcePoint), if_match=3 } → { endpoints=1, points=2 (int64), etag=3 }; Workload{ identity=1, labels=2, ip=3, name=4, sensor=5, people=6 (repeated), lease_from=7 (repeated) }; EnforcePoint{ id=1, kind=2, governs=3 (repeated), identity=4, labels=5, address=6 }; DistributeSegmentPolicyRequest{} → SegmentDistribution{ deployed=1, outcome=2, not_attempted=3, rolled_back=4, rollback_unavailable=5, rollback_failed=6, decision=7 (GateDecision{ mode=1, results=2 (GuardrailResult{ guardrail=1, verdict=2, reason=3, scope=4 }), blast_scope=3 }), result=8 (CompileResult{ tenant=1, version=2, rulesets=3 (RuleSet{ point=1, kind=2, version=3, rules=4 (CompiledRule{ intent_id=1, action=2, priority=3, from_identity=4, from_cidr=5, from_any=6, to_identity=7, to_cidr=8, to_any=9, ports=10, to_fqdn=11 }), unresolved=5, digest=6, intent=7 }), unresolved=4 }) }; AttestSegmentRequest{} → { drifts=1 (Drift{ point=1, drifted=2, expected=3, attested=4, orphan=5, backend=6, drift_kind=7 }) }; SimulateSegmentFlowRequest{ from=1, to=2 (Workload), proto=3, port=4 (int64) } → SegmentDecision{ action=1, intent_id=2, priority=3 }. Lawful-intercept writes (2026-09-30gp, pinned in grpc_bridge_lawintercept.go): RequestInterceptWarrantRequest{ authority=1, legal_ref=2, selectors=3 (repeated), categories=4 (repeated), justification=5, oversight_ref=6, valid_seconds=7 (int64) }, AuthorizeInterceptWarrantRequest{ id=1 }, DenyInterceptWarrantRequest{ id=1, reason=2 }, RevokeInterceptWarrantRequest{ id=1, reason=2 } → InterceptWarrant{ id=1, tenant=2, authority=3, legal_ref=4, requester=5, selectors=6, categories=7, justification=8, status=9, armed=10 (bool), oversight_ref=11, requested_at=12, approver=13, authorized_at=14, expires_at=15, armed_at=16, resolved_at=17, reason=18 } (times RFC 3339, empty when unset); ActivateInterceptionRequest{ id=1 }, DeactivateInterceptionRequest{ id=1, request_id=2 } → Outcome (as ProposeAction); RecordInterceptHandoverRequest{ warrant_id=1, interface=2, selector=3, category=4, content_hash=5, bytes=6 (int64), metadata=7 (map<string,string>), delivered_to=8 } → InterceptHandover{ id=1, tenant=2, warrant_id=3, interface=4, selector=5, category=6, content_hash=7, bytes=8 (int64), metadata=9, delivered_to=10, recorded_at=11 }. Integration writes (2026-09-30cn, pinned in grpc_bridge_connectors.go): EnableConnectorRequest{ id=1 }, DisableConnectorRequest{ id=1 } → Connector{ id=1, kind=2, name=3, version=4, summary=5, enabled=6 (bool), tenant=7, protocols=8 (repeated), emits=9 (repeated), agent_native=10 (bool), plugin=11 (PluginInfo{ tier=1, digest=2, key_id=3, capabilities=4 (repeated), size_bytes=5 (int64) }), routes=12 (bool) }; SetConnectorRoutingRequest{ id=1, route=2 (optional bool), tenant=3 } → ConnectorRouting{ tenant=1, connector=2 (Connector) }; RegisterPostureSourceRequest{ id=1, name=2, tenant=3, config=4 (map<string,string>) } → PostureSourceRegistration{ registered=1, connector=2, tenant=3, polling=4 (bool) }; DeletePostureSourceRequest{ id=1, name=2, tenant=3 } → PostureSourceRemoval{ removed=1, connector=2, tenant=3 }; PollPostureSourceRequest{ id=1, name=2, tenant=3 } → PostureSourcePoll{ started=1 (bool), connector=2, name=3, tenant=4 }; PutConnectorCredentialRequest{ id=1, name=2, tenant=3, config=4 (map<string,string>) } → ConnectorCredentialStored{ stored=1, connector=2, tenant=3 }; DeleteConnectorCredentialRequest{ id=1, name=2, tenant=3 } → ConnectorCredentialRemoval{ removed=1, connector=2, tenant=3 }; InstallPluginRequest{ descriptor=1, wasm=2 (bytes), name=3, summary=4, docs=5 } and InstallTenantPluginRequest{ …1–5, tenant=6 } → Connector, with PluginDescriptor{ id=1, version=2, schema=3, tier=4, capabilities=5 (repeated), budget=6 (PluginBudget{ max_steps=1, max_memory_mb=2, deadline_ms=3 }, all uint64), module_digest=7, key_id=8, signature=9 (bytes) }; UninstallPluginRequest{ id=1 } and UninstallTenantPluginRequest{ id=1, tenant=2 } → PluginRemoval{ removed=1, tenant=2 }; TrustPluginKeyRequest{ key_id=1, public_key=2 (bytes), tier=3 } → PluginKeyTrust{ trusted=1, keys=2 (repeated) }; TrustTenantPluginKeyRequest{ key_id=1, public_key=2 (bytes), tier=3, tenant=4 } → TenantPluginKeyTrust{ trusted=1, tenant=2, tier=3, keys=4 (PluginKey{ key_id=1, tier=2, tenant=3, bound=4, revoked=5 }) }; RevokePluginKeyRequest{ id=1 } and RevokeTenantPluginKeyRequest{ id=1, tenant=2 } → PluginKeyRevocation{ revoked=1, tenant=2 }; RebindPluginKeyRequest{ id=1, tier=2, tenant=3 } → PluginKeyBinding{ rebound=1, tier=2, tenant=3 }. Support-desk writes (2026-09-30b, pinned in grpc_bridge_support.go): OpenSupportTicketRequest{ severity=1, category=2, summary=3, description=4, contact=5, attach_bundle=6 (bool), consent=7 (bool) }, CommentSupportTicketRequest{ id=1, text=2 }, TransitionSupportTicketRequest{ id=1, status=2, note=3 }, AttachSupportBundleRequest{ id=1, consent=2 (bool) }, EscalateSupportTicketRequest{ id=1, target=2, note=3, case_id=4, defect=5 } → SupportTicket{ id=1, reference=2, tenant=3, severity=4, category=5, summary=6, status=7, opened_at=8, updated_at=9, escalated=10, bundle_attached=11, sla=12 (SupportSLA{ tracked_on=1, declared=2, ack_due_at=3, resolve_due_at=4, acked_at=5, ack_breached_at=6, resolve_breached_at=7, resolve_leg_from=8 }), description=13, contact=14, opened_by=15, plan=16, comments=17 (SupportComment{ index=1 (int64), time=2, author=3, side=4, text=5 }), allowed_transitions=18, side=19, response_target=20 (SupportResponseTarget{ declared=1, acknowledge=2, resolve=3, note=4 }), bundle=21 (SupportBundle{ digest=1, size=2 (int64), attached_at=3, attached_by=4, consent=5 }), escalation=22 (SupportEscalation{ at=1, by=2, target=3, note=4, linked_case=5, linked_defect=6 }), delivery=23 (SupportDelivery{ state=1, delivered_update=2, latest_update=3 }) } (times RFC 3339, empty when unset; an undeclared target stays declared=false). Analysis writes (2026-10-01an, pinned in grpc_bridge_analyze.go): the forty-six window doors take AnalyzeRequest{ since=1, until=2 } (RFC 3339, forwarded as the REST query) → AnalysisResult{ count=1 (int64), items=2 (repeated google.protobuf.Struct) }; AnalyzeMemoryRequest{ image=1 (bytes), host=2, case=3, manifest=4, sha256=5, reason=6, source_event=7, pslist=8 (repeated), legs=9 (repeated) } → google.protobuf.Struct (the REST report, whole). Struct/Value/ListValue are protobuf's well-known struct.proto types, and every Value oneof member is written even at its zero value. MFA writes (2026-10-01gx, pinned in grpc_bridge_mfa.go): RegisterMFADeviceRequest{ name=1, public_key=2 (bytes) } → MFADevice{ device_id=1, name=2, added=3 }; RevokeMFADeviceRequest{ id=1 } → MFADeviceRevocation{ revoked=1 (bool), id=2, name=3 }; ResolveMFAChallengeRequest{ id=1, device_id=2, decision=3, signature=4 (bytes), number=5 (optional int32), proofs=6 (repeated MFAFactorProof{ factor=1, signature=2 (bytes), code=3, number=4 (optional int32), sig_version=5 (optional int32) }), sig_version=7 (int32) } → MFAChallengeResolution{ id=1, status=2, factors=3 (repeated) }; SelfTestMFARequest{ rp=1, context=2, factors=3 (repeated) } → MFASelfTestChallenge{ challenge_id=1, number=2 (int32), required_factors=3 (repeated) }; sig_version (2026-10-14p, owner §6eb ②) says which statement a signature covers — absent/0 the legacy untagged id|decision|nonce (mfa.SigningString / FactorSigningString), 1 the tagged mfa.DecisionStatement (eventlake-mfa-decision/v1) / FactorStatement (eventlake-mfa-factor/v1); a device sends 1 only when the REST challenge row advertised it in sig_versions; AddMFAFactorRequest{ id=1, factor=2, public_key=3 (bytes), totp_secret=4, pin_scheme=5, attestation_chain=6 (repeated bytes, DER, leaf first — review §2.5), app_attestation=7 (bytes, Apple App Attest CBOR), app_attest_key_id=8 (bytes, 32) — tech-stack I5, mfa/appattest.go } → MFAFactorEnrolment{ device_id=1, factor=2, totp_secret=3, attestation_level=4 }; EvaluateMFAPINRequest{ id=1, blinded_element=2 (bytes), enroll=3 (bool) } → MFAPINEvaluation{ evaluated_element=1 (bytes), suite=2 }; SetMFAPolicyRequest{ factors=1 (repeated) } → MFAPolicy{ factors=1 }; SetMFATenantPolicyRequest{ factors=1 (repeated) } → MFATenantPolicy{ tenant=1, factors=2 }; SetMFATenantAttestationRequest{ min_attestation=1 } → MFATenantAttestation{ tenant=1, min_attestation=2 }; AddTOTPAccountRequest{ issuer=1, label=2, secret=3, uri=4, digits=5 (int32), period=6 (int32), algorithm=7 } → TOTPAccount{ id=1, issuer=2, label=3, uri=4, digits=5, period=6, algorithm=7 }; RemoveTOTPAccountRequest{ id=1 } → TOTPAccountRemoval{ removed=1 (bool), id=2, issuer=3, label=4 }; VerifyTOTPRequest{ user=1, code=2, rp=3, ip=4 } → TOTPVerification{ valid=1 (bool), user=2, issuer=3 }. MFA security-key writes (2026-10-01fk), same file — WebAuthn's own JSON documents cross as STRINGS, never re-typed: BeginMFASecurityKeyEnrolmentRequest{} → MFASecurityKeyCeremony{ ceremony_id=1, public_key_json=2 }; FinishMFASecurityKeyEnrolmentRequest{ ceremony_id=1, label=2, credential_json=3 } → MFASecurityKey{ id=1, label=2, alg=3 (int64), attestation_format=4, attestation_trust=5, added=6, clone_suspected=7 (bool), last_used=8 }; RevokeMFASecurityKeyRequest{ id=1 } → MFASecurityKeyRevocation{ revoked=1 (bool), id=2, label=3 }; ProveMFASecurityKeyRequest{ id=1, credential_json=2 } → MFASecurityKeyProof{ id=1, securitykey_proven=2 (bool), status=3 }. A credential_json that is not JSON is INVALID_ARGUMENT before any door. Self-scoped writes (2026-10-01w, pinned in grpc_bridge_me.go; no request names a subject or tenant): SetMyLocaleRequest{ optional locale=1 } → MyLocale{ preference=1, tenant_default=2, locale=3, locale_source=4, shipped=5 (repeated) }; CreateMyDeviceEnrollTokenRequest{ device_id=1, key_id=2, kind=3 } → MyDeviceEnrollToken{ token=1, device_id=2, key_id=3, tenant=4, subject=5, tier=6, kind=7, expires_in=8 (int64) }; RemoveMyDeviceRequest{ id=1 } and RevokeMySessionRequest{ ref=1 } → google.protobuf.Empty; RevokeMySessionsRequest{ include_current=1 (bool, as?include_current=) } → MySessionRevocation{ revoked=1 (int64), kept_current=2, still_signed_in=3 }; DisputeMyAccessRecoveryRequest{ id=1 } → MyAccessRecoveryDispute{ id=1, state=2, disputed=3 (bool), disputed_at=4 } (2026-10-04aa; marks the request disputed for the admin, never cancels it); LockDownMyAccountRequest{} → {locked_down=1, sessions_ended=2 (int64), recovery_frozen=3, frozen_until=4, recoveries_denied=5 (int64), still_signed_in=6} and LiftMyLockdownRequest{} (step-up posture) → {recovery_frozen=1} (2026-10-20x, one-tap lockdown); SetMyConsentRequest{ purpose=1, categories=2 (repeated), granted=3 (bool), expires_seconds=4 (int64) } → MyConsentDecision{ purpose=1, status=2, consent=3 (ConsentReceipt{ subject=1, purpose=2, categories=3, tenant=4, granted_at=5, expires_at=6, status=7, updated_at=8 }) }; FileMyRightRequest{ right=1, field=2, value=3, reason=4, detail=5, category=6 } → MyRightFiling{ correction=1 (Correction{ subject=1, tenant=2, field=3, value=4, reason=5, status=6, requested_at=7, updated_at=8, fulfilled_at=9 }), erasure=2 (Erasure{ subject=1, tenant=2, reason=3, status=4, fields=5, requested_at=6, fulfilled_at=7 }), grievance=3 (Grievance, as in the platform writes below) } (exactly one set); SetMyReportSubscriptionRequest{ every=1, optional subscribed=2 (bool) } → MyReportSubscription{ subscribed=1, deliverable=2, min_every=3, max_every=4, not_covered=5 (repeated NotCovered{ plane=1, why=2 }), every=6, last_sent=7, next_due=8, to=9 }; ReportMyDevicePostureRequest{ report_json=1 (bytes) } → MyDevicePosture{ accepted=1 (int64) }; VerifyMyTOTPRequest{ code=1, rp=2, ip=3 } → TOTPVerification (as above). Validation writes (2026-10-02u, pinned in grpc_bridge_validation.go; every answer is google.protobuf.Struct, the REST body whole): CreateValidationEngagementRequest{ name=1, scope=2 (repeated ValidationTarget{ kind=1, value=2 }), exclusions=3 (repeated ValidationTarget), max_tier=4, max_blast=5 (int64), ttl_seconds=6 (int64) } → the signed engagement; RunValidationCampaignRequest{ engagement_id=1, posture=2 (ValidationPosture{ assets=1 (repeated ValidationAsset{ ref=1, kind=2, ip=3, criticality=4, services=5 (repeated ValidationService{ port=1 (int64), proto=2, product=3, version=4, url=5, likely_default_creds=6 (bool) }) }), weaknesses=2 (repeated ValidationWeakness{ cve=1, asset_ref=2, technique=3, title=4 }) }), posture_window_seconds=3 (int64) } → the campaign answer (an absent or empty posture runs the live projection, as on REST); RunValidationCampaignFromThreatPathsRequest{ engagement_id=1, top_n=2 (int64), window_seconds=3 (int64) } → the campaign answer plus seeded_by ("threatpath"; the retired "mythos" rides beside it as seeded_by_retired until the owner's Kind C date) and hypotheses; ScoreValidationCampaignCoverageRequest{ id=1 } → {campaign_id, detection_rate, gaps}; RunSelfEngagementRequest{} → the self-engagement report. AcknowledgeValidationRunRequest{ id=1 } → {acknowledged, run_id} (2026-10-20x, a run a restart cut short). RevokeDeskExceptionRequest{ id=1, reason=2 } → {revoked, outcome} (2026-10-20x, withdraw a granted desktop exception). Fleet-rollout writes (2026-10-02c, pinned in grpc_bridge_rollouts.go): CreateRolloutRequest{ rollout_json=1 (bytes) } → RolloutCreation{ created=1, artifact_json=2 (bytes), rings=3 (int64), state=4 }; ImportRolloutBundleRequest{ bundle_json=1 (bytes) } → RolloutImport{ imported=1, artifact_json=2 (bytes) }; PromoteRolloutRequest{ id=1 } → RolloutPromotion{ promoted=1, ring=2 (int64), state=3 }; HaltRolloutRequest{ id=1, reason=2 } → RolloutHalt{ halted=1, reason=2 }; RollbackRolloutRequest{ id=1, reason=2 } → RolloutRollback{ rolled_back=1 }. Posture writes (2026-10-02e, pinned in grpc_bridge_posture.go): EvaluateHardeningRequest{ asset=1, baseline=2, state=3 (map<string,string>), osquery_json=4 (bytes), inventory_json=5 (bytes), audit_policy_json=6 (bytes) } and EnforceHardeningRequest{ the same six, source_rule=7, confidence=8 (double) } → google.protobuf.Struct; SnapshotPKIPostureRequest{ since=1, until=2 } (as ?since=&until=) → google.protobuf.Struct; AddDLPDatasetRequest{ kind=1, dataset=2, values=3 (repeated), doc_id=4, text=5 } and RemoveDLPDatasetRequest{ kind=1, dataset=2, values=3 (repeated), doc_id=4, all=5 (bool) } → DLPDatasetChange{ kind=1, dataset=2, added=3 (int64), records=4 (int64), created=5, doc_id=6, removed=7 (int64), removed_dataset=8 }; SetDLPDatasetEdgeRequest{ optional tenant=1, kind=2, dataset=3, edge=4 (bool) } → DLPDatasetEdge{ kind=1, dataset=2, edge=3 }; SetMaskingPolicyRequest{ policy_json=1 (bytes), reason=2 } and PreviewMaskingRequest{ sample=1, policy_json=2 (bytes) } → google.protobuf.Struct (2026-10-13u, the tenant's DLP masking policy). Sensor writes (2026-10-01ab, pinned in grpc_bridge_sensor.go; no request names a tenant): RegisterSensorRequest{ registration_json=1 (bytes) } → SensorRegistration{ registered=1, wire_min=2 (int64), wire_max=3 (int64), tenant=4, feed_key_id=5, feed_key=6, feed_key_chain_json=7 (bytes), tenant_declaration_json=8 (bytes) } (refusal detail on the below-floor 409: SensorWireRange{ wire_min=1, wire_max=2 }); ReportSensorResultRequest{ command_id=1, agent_id=2, applied=3 (bool), detail=4, simulated=5 (bool), refused=6 (bool), err=7, at=8 } and AckSensorDetectionRequest{ agent_id=1, rule_id=2, version=3 (int64), digest=4, verified=5 (bool), applied=6 (bool), detail=7, err=8, at=9, retired=10 (bool) } → SensorRecorded{ recorded=1 }; AckSensorUpdateRequest{ agent_id=1, rollout_id=2, version=3, ok=4 (bool), detail=5 } → SensorUpdateAck{ recorded=1, ok=2 }; RecordSensorSessionChunkRequest{ session_id=1, seq=2 (int64), stream=3, data=4 (bytes) } → SensorSessionChunk{ recorded=1 (bool), seq=2 (int64), chain_head=3 } (refusal detail on a self-termination 409: SensorSessionTerminated{ terminated=1 }); CloseSensorSessionRequest{ session_id=1 } → SensorSessionClosed{ closed=1, session=2 (PAMSession) }; GrantLeaseAuthorityRequest{ baseline=1, os=2 } and RevokeLeaseAuthorityRequest{ baseline=1, os=2, request_id=3 } → Outcome; SetSensorProfileLegRequest{ leg=1, optional arm=2 (bool), tenant=3 } → SensorProfileLeg{ tenant=1, leg=2, title=3, armed=4, disarmed=5 (repeated SensorLegDecision{ leg=1, actor=2, when=3 }) }. Verification writes (2026-10-03w, pinned in grpc_bridge_verify.go; no request names a tenant): VerifyDocumentRequest{ document=1 (bytes), filename=2 }, VerifyAadhaarQRRequest{ payload=1 }, VerifyAadhaarKYCRequest{ zip=1 (bytes), share_code=2, consent_ref=3 }, VerifyServiceRequest{ path=1, parameters=2 (map<string,string>), idempotency_key=3, consent_ref=4, composite=5 (bool), document=6 (bytes) } and RegisterVerificationConsentRequest{ subject=1, purpose=2, services=3 (repeated), method=4, evidence_sha256=5, collected_at=6, expires_at=7 } → google.protobuf.Struct (REST's body); EraseVerificationSubjectRequest{ subject=1, reason=2 } → VerificationErasure{ subject_erased=1 (bool), key_destroyed=2 (bool), consent_refs=3 (int64, a count), note=4 }. Refusal details: VerificationCap{ allowed=1 (bool), family=2, plan=3, used=4 (int64), included=5 (int64), soft=6 (bool), reason=7 } on a 429; VerificationDependency{ fix=1 } on a 424. Tenant-admin writes (2026-10-03ad, pinned in grpc_bridge_tenant.go; no request names a tenant): CreateTenantInvitationRequest{ email=1, who=2, role=3, audience=4, ttl_hours=5 (int64) } → Invitation (the platform family's message); RevokeTenantInvitationRequest{ id=1 } → TenantInvitationRevocation{ id=1, state=2 }; RevokeTenantUserRequest{ id=1 } → TenantUserRevocation{ id=1, revoked=2 (bool), who=3, sessions_ended=4 (int64) }; EndTenantSessionsRequest{ who=1 } (REST's ?who=) and EndTenantSessionRequest{ ref=1 } → TenantSessionsEnd{ ended=1 (int64), who=2 }; ResolveAccessRecoveryRequest{ id=1, resolution=2, email=3, role=4 } → Invitation (a denial sets id + state only); RunTenantSealCheckRequest{ since=1, until=2 } (REST's ?since=/?until=, RFC 3339; 2026-10-12ba, B39) → TenantSealRun{ tenant=1, verdict=2, at=3, since=4, until=5, checked=6 (int64), failed=7 (int64), first_failure=8 (SealFailure), cap=9 (int64) }, SealFailure{ event_id=1, time=2, key=3, reason=4 } — verdict is intact · failed · incomplete · empty, and only intact claims integrity. SetTenantAccessPolicyRequest{ ranges=1 (repeated), idle_minutes=2 (int64), lifetime_minutes=3 (int64), reason=4 } (B40; absent ranges is refused, never "allow all") → TenantAccessPolicy{ ranges=1 (repeated), idle_minutes=2, lifetime_minutes=3, version=4, default=5 (bool), node_max_idle_minutes=6, node_max_lifetime_minutes=7, effective_idle_minutes=8, effective_lifetime_minutes=9 (all int64), your_address=10, reason=11, set_by=12, updated=13 }. Policy-plane writes (2026-10-03af, pinned in grpc_bridge_policy.go; no request names a tenant): PolicySelector{ identity=1, labels=2 (map<string,string>), cidr=3, any=4 (bool) }; SetPolicyDocumentRequest{ id=1, domain=2, selector=3 (PolicySelector), spec_json=4 (bytes), priority=5 (int64), if_match=6 } → google.protobuf.Struct (the document view, etag and spec_verdict included); WithdrawPolicyDocumentRequest{ id=1, if_match=2 } (path id; ?if_match=) → PolicyDocumentWithdrawn{ withdrawn=1 }; PolicyTarget{ agent=1, identity=2, labels=3 (map<string,string>), address=4 }; PolicyTargetsRequest{ targets=1 (repeated PolicyTarget), version=2 (int64) } for CompilePolicy, StagePolicy and ReconcilePolicy, and AssurePolicyRequest{ targets=1, version=2, window_minutes=3 (int64) } → google.protobuf.Struct (REST's body). Backfill writes (2026-10-03al, pinned in grpc_bridge_backfill.go; nested values ride as the REST body's own JSON bytes): CreateBackfillRequest{ job_json=1 (bytes) }, RunBackfillRequest{ id=1, slices=2 (int64, REST's ?slices=) }, PauseBackfillRequest{ id=1 } and ResumeBackfillRequest{ id=1 } → BackfillJob{ id=1, tenant=2, source_json=3, since=4, until=5, chunk=6 (int64), retain_original=7 (bool), connector_routes_json=8, no_decode=9 (bool), auto=10 (bool), paused=11 (bool), slices_per_run=12, failures=13, status=14, cursor=15, boundary_ids=16 (repeated), landed=17, bytes=18, slices=19, dense_skips=20, decoded=21, undecoded=22, sources_json=23, sources_truncated=24, error=25, started_at=26, completed_at=27 } (counts int64); RetroScanBackfillRequest{ id=1 } → BackfillRetroScan{ tenant=1, window_json=2, events_replayed=3, alerts_fired=4, truth_positives=5, truth_negatives=6, truth_source=7, rules_json=8, overall_json=9, critical_fires=10, correlations_fired=11, correlations_replayed=12 (repeated), incomplete=13 (bool), incomplete_reason=14, unreplayed_correlations=15 (repeated), evidence_by_rule_json=16, fires_json=17, fires_truncated=18, generated_at=19 }. Refusal detail: BackfillRunRefusal{ job=1 (BackfillJob) } on the run's 502 (UNAVAILABLE). Detection-compiler writes (2026-10-03am, pinned in grpc_bridge_compiler.go; no request names a tenant): CompilerWindowRequest{ since=1, until=2 } (REST's ?since=/?until=, RFC 3339) for CompileDetectionRecommendations and CompileEdgeCandidates → google.protobuf.Struct (count · recommendations); DeployEdgeRecommendationRequest{ rule=1, since=2, until=3 } → google.protobuf.Struct (rule · deployed · decision · artifact); ApplyDetectionRecommendationRequest{ rule=1, action=2 } (suppress | promote) → DetectionRecommendationApplied{ rule=1, action=2, applied=3 (bool) }. Staged edge rollouts (2026-10-22f, same file; step-up doors at the bearer posture, the rule as the path segment): PromoteEdgeStageRequest{ rule=1 } → EdgeStagePromotion{ promoted=1, stage_json=2 (bytes) }; RearmEdgeStageRequest{ rule=1 } → EdgeStageRearm{ rearmed=1, stage_json=2 (bytes) } (404 edgestage.unknown is NOT_FOUND; a wrong phase is FAILED_PRECONDITION). Exposure writes (2026-10-03aq, pinned in grpc_bridge_exposure.go; nested values ride as the REST body's own JSON bytes): ScanExposureRequest{ surface_json=1 (bytes) } → ExposureReport{ tenant=1, scanner=2, generated_for=3, scope_active=4, scope_skipped=5, hosts=6, services=7, findings_json=8, critical=9, high=10, medium=11, low=12, kev_exposed=13, dangling_dns=14, expiring_certs=15, shadow_it=16, lookalikes=17, by_kind_json=18, findings_total=19, findings_truncated=20 (bool), truncated=21 (bool), note=22 } (counts int64); WatchExposureBrandRequest{ observations_json=1 (bytes) } → ExposureBrandReport{ tenant=1, source=2, generated_for=3, apexes_watched=4, observed=5, skipped=6, malformed=7, merged=8, findings_json=9, findings_total=10, findings_truncated=11 (bool), note=12, critical=13, high=14, medium=15, registered_only=16, cert_backed=17, staged=18, by_match_json=19 }; DeclareExposureScopeRequest{ scope_json=1 (bytes, REQUIRED) } and VerifyExposureScopeAssetRequest{ kind=1, value=2, method=3 } → ExposureScope{ tenant=1, assets_json=2, allow=3 (repeated) }; AssessMailPostureRequest{ records_json=1 (bytes; absent = REST's empty body, the wired resolver) } → MailPostureReport{ tenant=1, source=2, generated_for=3, domains=4, findings_json=5, records_json=6 } (2026-10-14, the mail-posture assess door of §13 row 131). Lead-plane writes (2026-10-04xb, pinned in grpc_bridge_leads.go; no request names an actor; every answer is a google.protobuf.Struct carrying REST's JSON): UpdateLeadRequest{ id=1, status=2, owner=3, plan_interest=4, tenant=5 (all four optional string — presence carried) } → (lead); AddLeadActivityRequest{ id=1, kind=2 (absent = note), text=3 } → (activity); EraseLeadRequest{ id=1, reason=2 (rides as ?reason=) } → (lead · erased · actor · erased_at); ConvertLeadRequest{ id=1, tenant=2, plan=3, role=4 (absent = REST's admin default), who=5, ttl_hours=6 (int64), country=7 } → (tenant · status · invitation · lead · lead_joined · …); its already-converted 409 → FAILED_PRECONDITION + LeadAlreadyConverted{ tenant=1 }. Key-root writes (2026-10-04m, pinned in grpc_bridge_keyroots.go; every answer is a google.protobuf.Struct carrying REST's JSON): EnrollKeyRootRequest{ tenant=1 (Platform only, REST's rule), mode=2, material=3 (base64 text) } → (enrolled · root); RotateKeyRootRequest{ tenant=1, mode=2, material=3 } → (rotated · root · rewrapped); RevokeKeyRootRequest{ tenant=1, reason=2 } → (the conductor outcome: Status · RequestID · …); ApproveKeyRootRevokeRequest{ id=1 } (REST's path {id}) → (outcome · revoked · root). OIDC-provider writes (2026-10-04i, pinned in grpc_bridge_oidc.go; no request names a subject; every answer is a google.protobuf.Struct carrying REST's JSON): RegisterOIDCClientRequest{ name=1, redirect_uris=2 (repeated), scopes=3 (repeated), tenant=4 (Platform only, REST's rule), estate=5 } → (client_id · client_secret · name · tenant · estate · redirect_uris · scopes); AuthorizeOIDCRequest{ client_id=1, redirect_uri=2, response_type=3, scope=4, state=5, nonce=6, code_challenge=7, code_challenge_method=8, acr_values=9, claims=10 (the GET door's JSON string), max_age=11 (optional int64) } → (code · redirect); ApproveOIDCAuthorizationRequest{ id=1 } and DenyOIDCAuthorizationRequest{ id=1 } (REST's path {id}) → (redirect). Enterprise-SSO writes (2026-10-04ab, pinned in grpc_bridge_sso.go; no request names an actor; every answer is a google.protobuf.Struct carrying REST's JSON): RegisterSSOIdPRequest{ idp_json=1 (bytes, the whole REST body) } → (registered · tenant · issuer · enforce_sso · …); DeleteSSOIdPRequest{ id=1 } (REST's path {id}) → (removed); IssueSCIMTokenRequest{ name=1, tenant=2 } (tenant honoured for a Platform key only) → (id · token · tenant); RevokeSCIMTokenRequest{ id=1 } (REST's path {id}) → (revoked); DisableSSOIdPRequest{ id=1 } and EnableSSOIdPRequest{ id=1 } (REST's path {id}, both step-up, 2026-10-10ai) → (id · tenant · disabled · sso_enforced · warning when a disable lifts enforcement). Lawful-monitoring writes (2026-10-03ax, pinned in grpc_bridge_monitoring.go; no request names a tenant or an actor; every answer is a google.protobuf.Struct carrying REST's JSON): SetMonitoringProfileRequest{ profile_json=1 (bytes, the whole REST body) } → (profile); RequestMonitoringRevealRequest{ pseudonym=1, case=2, justification=3 } → (reveal); ApproveMonitoringRevealRequest{ id=1 } (REST's path {id}) → (reveal); DenyMonitoringRevealRequest{ id=1, reason=2 } (reason optional) → (reveal). Own-model pilot writes (2026-10-03ar, pinned in grpc_bridge_model.go; no request names a tenant; every answer is a google.protobuf.Struct carrying REST's JSON): ModelCorpusRequest{ since=1, until=2 } (REST's ?since=/?until=) for CutModelCorpus → (digest · pairs · masked · sources · tenants · dropped_*); ModelCanaryRequest{} for CanaryModel → (comparison · candidate · incumbent); RegisterModelRequest{ name=1, version=2, base=3, provider=4, endpoint=5, weights_hash=6, corpus_digest=7, eval_digest=8 } → (artifact); PromoteModelRequest{ id=1, action=2 } (promote | rollback) → (id · action · ok · refused? · reason? · decision?). Wallet writes (2026-10-03ao, pinned in grpc_bridge_wallet.go; no request names a tenant or a holder): IssueWalletCredentialRequest{ user=1, type=2 (repeated), claims_json=3 (bytes), selective=4 (repeated), ttl_seconds=5 (int64) } → IssuedWalletCredential{ credential_id=1, type=2 (repeated), jwt=3, holder_did=4, issuer_did=5 }; WalletDisclose{ credential_id=1, reveal=2 (repeated), reveal_all=3 (bool) }; PresentWalletCredentialsRequest{ credential_ids=1 (repeated), disclose=2 (repeated WalletDisclose), audience=3, nonce=4 } → WalletPresentation{ vp_jwt=1, holder_did=2 }; VerifyWalletPresentationRequest{ vp_jwt=1, audience=2, nonce=3 } → WalletVerification{ valid=1 (bool), reason=2, holder=3, credentials_json=4 (bytes), issuer_did=5, trusted_issuers=6 (repeated) }; RevokeWalletCredentialRequest{ id=1 } (path id) → WalletCredentialRevocation{ revoked=1 }. Platform-operator writes (2026-10-01j, pinned in grpc_bridge_platform.go; every response time is RFC 3339, empty when unset): SetVerificationOverageRequest{ tenant=1, allow=2 (bool) } → VerificationOverage{ tenant=1, allow=2, changed=3 }; SetVerifyCredentialRequest{ tenant=1, publisher=2, api_key=3, client_id=4, expires=5 } → VerifyCredential{ slot=1 (VerifyCredentialSlot{ tenant=1, publisher=2, fingerprint=3, created=4, rotated=5, expires=6, revoked=7 }) }; RevokeVerifyCredentialRequest{ tenant=1, publisher=2 } (as ?tenant=&publisher=) → VerifyCredentialRevocation{ tenant=1, publisher=2, revoked=3 }; AssignLicenseRequest{ tenant=1, lines=2 (repeated), features=3 (repeated), wind_down=4 (bool) } (no lines element is refused as REST refuses an omitted lines; the wind-down is wind_down=true with no line, forwarded as "lines": []; both together are refused — 2026-10-01v) and UnassignLicenseRequest{ tenant=1 } (as ?tenant=) → LicenseAssignment{ tenant=1, lines=2, provisioned=3, features=4 }; CreateLicenseRequestRequest{ tenant=1, lines=2, features=3, note=4 }, FulfilLicenseRequestRequest{ id=1, ref=2, document=3 } (document = the signed licence file verbatim; the ref is DERIVED from it and ref=2 is only a cross-check — Row 81 option A, 2026-10-15aa), WithdrawLicenseRequestRequest{ id=1 } → LicenseRequestState{ id=1, tenant=2, state=3, ref=4, fulfilment_status=5 }; CreateTenantRequest{ tenant=1, plan=2, admin_who=3 } → TenantCreation{ tenant=1, status=2, plan=3, credential=4 }; IssueTenantCredentialRequest{ tenant=1, who=2, role=3, audience=4, tier=5 } and IssueOperatorCredentialRequest{ who=1, ttl=2 } → IssuedCredential{ id=1, who=2, role=3, audience=4, tier=5, created=6, revoked=7, key=8, platform=9, expires=10, expired=11 } (IssueOperatorCredentialRequest gained role=3, roles=4 — the OEM platform roles — in 2026-10-10af); RequestPlatformElevationRequest{ reason=1, ticket=2, ttl=3 }, ApprovePlatformElevationRequest{ id=1 } and DeclinePlatformElevationRequest{ id=1 } → PlatformElevation{ id=1, operator=2, credential_id=3, role=4, reason=5, ticket=6, ttl=7, status=8, created=9, decided_by=10, decided=11, approver_bootstrap=12, expires=13, ended_by=14, ended=15, seconds_left=16 } (2026-10-10af, ops-access P1-1); RevokeTenantCredentialRequest{ id=1 } and RevokeAccessGrantRequest{ id=1 } → Revocation{ id=1, revoked=2 }; SetTenantStatusRequest{ tenant=1, status=2 } and CancelTenantOffboardingRequest{ tenant=1 } → TenantStatus{ tenant=1, status=2 }; BeginTenantOffboardingRequest{ tenant=1 } → TenantOffboarding{ tenant=1, status=2, offboarding_started_at=3, offboarding_started_by=4, sessions_ended=5 (int64) }; PurgeTenantRequest{ tenant=1, confirm_tenant=2, reason=3 } → TenantPurge{ certificate_json=1 (bytes), sessions_ended=2 (int64) }; SetTenantJurisdictionRequest{ tenant=1, country=2 } → TenantJurisdiction{ tenant=1, country=2, source=3, set_by=4, at=5 }; ResolveScreeningReviewRequest{ id=1, decision=2, note=3 } → ScreeningReview{ id=1, door=2, verdict=3, reason=4, rule=5, country=6, email_domain=7, subject=8, party_key=9, status=10, opened=11, resolved_by=12, note=13 }; RecordLIEligibilityRequest{ tenant=1, kind=2, authority=3, authorisation_ref=4, jurisdiction=5, counsel_ref=6 }, CountersignLIEligibilityRequest{ tenant=1, id=2 }, RevokeLIEligibilityRequest{ tenant=1, id=2, reason=3 } (a blank reason is refused, as on REST) → LIEligibility{ id=1, tenant=2, kind=3, authority=4, authorisation_ref=5, jurisdiction=6, counsel_ref=7, recorded_by=8, countersigned_by=9, revoked=10, revoked_by=11, recorded=12, revoked_at=13, revoke_reason=14 }; CreatePlatformInvitationRequest{ tenant=1, email=2, who=3, role=4, audience=5, tier=6, ttl_hours=7 (int64) } → Invitation{ id=1, tenant=2, email=3, who=4, role=5, audience=6, tier=7, invited_by=8, created=9, expires=10, state=11, delivered=12, redeemed_at=13, credential_id=14, link=15, delivery=16 }; CreateAccessGrantRequest{ tenant=1, reason=2, ticket=3, ttl=4, scope=5 } → AccessGrant{ id=1, operator=2, tenant=3, reason=4, ticket=5, created=6, expires=7, revoked=8, ended_by=9, active=10, state=11, requested_ttl=12, decided_by=13, decided=14, scope=15 } (ttl at most 4h and scope "" | config since 2026-10-14az, B36); SetEgressPolicyRequest{ optional tenant=1, allow=2 (repeated), allow_sinks=3 (repeated) } → EgressPolicySet{ policy=1 (EgressPolicy{ tenant=1, allow=2, unbounded=3, allow_sinks=4, sinks_unbounded=5, set_by=6, updated=7 }) }; ClearEgressPolicyRequest{ tenant=1 } (_default = the estate default) → EgressPolicyClear{ cleared=1 }; ActOnConsumerRightRequest{ right=1, tenant=2, subject=3, field=4, id=5, to=6, resolution=7 } → ConsumerRightAct{ right=1, tenant=2, subject=3, status=4, changed=5, grievance=6 (Grievance{ id=1, subject=2, tenant=3, category=4, summary=5, status=6, raised_at=7, due_at=8, updated_at=9, resolved_at=10, resolution=11 }) }; LoadExposureCorpusRequest{ corpus_json=1 (bytes) } → ExposureCorpusLoad{ accepted=1, rejected=2, dropped=3, corpus_breaches=4, corpus_identifiers=5, dropped_at_ingest=6 (all int64), corpus_updated=7 }; RunExposureWatchRequest{} → ExposureWatchRun{ subjects=1, entitled=2, seeded=3, notified=4, new_records=5 (all int64), corpus_unavailable=6 }.

Status mapping:

Condition gRPC status code
success OK 0
malformed request frame / bad message / missing required field INVALID_ARGUMENT 3
unknown report id NOT_FOUND 5
role lacks the permission PERMISSION_DENIED 7
unknown service/method UNIMPLEMENTED 12
missing/invalid API key UNAUTHENTICATED 16
a Command write's REST twin refused with 409 (state machine, task already done) or 410 (an expired compartment request) FAILED_PRECONDITION 9
a Command write's REST twin refused with 429 / 503 RESOURCE_EXHAUSTED / UNAVAILABLE 8 / 14
a bridged answer over the 4 MiB frame (the REST door RAN; since 2026-10-01an) RESOURCE_EXHAUSTED 8

Running it:

EVENTLAKE_GRPC_ADDR=:9090 go run ./cmd/eventlake-api
# a self-signed dev cert is minted for localhost (no secret in code); prod terminates a real cert here.
grpcurl -insecure localhost:9090 grpc.health.v1.Health/Check

EVENTLAKE_GRPC_TLS_CERT + EVENTLAKE_GRPC_TLS_KEY load a real certificate pair instead of the dev self-signed one. No compression is negotiated (the compressed-flag byte must be 0), and inbound messages are capped (maxGRPCMessage, mirroring the REST MaxBytesReader memory-amplification guard).

4.4 The SQL grammar (v1) and its response

SELECT <cols> FROM events
  [WHERE <expr>] [GROUP BY <fields>] [HAVING <expr over output columns>]
  [ORDER BY <output column> [ASC|DESC], ...] [LIMIT n [OFFSET m]]

cols  := * | col [, col]...        col := field | agg | (field|agg) AS alias
agg   := COUNT(*) | COUNT(field) | COUNT(DISTINCT field) | SUM(f) | AVG(f) | MIN(f) | MAX(f)
expr  := comparisons (= != <> < <= > >=) · IN (...) · LIKE 'pat' (% _, case-insensitive)
         · BETWEEN a AND b · IS [NOT] NULL · AND / OR / NOT / parentheses
field := id · time · ingest_time · severity · outcome · domain · category · type · activity
         · subject · subject_kind · object · object_kind · src_ip · dst_ip · observer
         · attributes.<key> (quote non-identifier keys: attributes."x-y") · labels.<key>

Severity compares by rank (severity >= 'high'; names or 0–4). Time literals are RFC 3339 or YYYY-MM-DD [HH:MM:SS]. Strings are single-quoted ('' escapes). Missing fields never match a comparison; IS NULL is the explicit absence test. tenant is not expressible (D08-4). FROM events is the one stream — alerts are category = 'alert', cases category = 'case'.

-- who is tripping detections, worst first
SELECT subject, count(*) AS n, max(severity) FROM events
WHERE category = 'alert' AND time >= '2026-07-15' GROUP BY subject
HAVING n >= 3 ORDER BY n DESC LIMIT 20

-- hunt: encoded PowerShell on any host
SELECT time, subject, attributes.host FROM events
WHERE attributes.command_line LIKE '%-encodedcommand%' ORDER BY time DESC

POST /v1/query/sql (canRead, per-tenant query-gated) — body {"sql": "SELECT ..."} →

{ "columns": [...], "rows": [[...], ...], "row_count": n, "scanned": n, "matched": n,
  "plan": {"pushdown": {"category": "alert", ...}, "post_filter": "(...)"},
  "truncated": true?, "scan_truncated": true? }

A compile error is a 400 carrying the parser's offset-anchored message — the analyst's syntax feedback.

4.5 The plugin ABI (v1), descriptor and packaging

Export exactly three things:

Export Signature Contract
memory linear memory rustc exports it by default for a cdylib
alloc (size: i32) -> i32 reserve size bytes; return the offset the host will write the raw frame at
decode (ptr: i32, len: i32) -> i64 decode the frame at [ptr, ptr+len); return (out_ptr << 32) \| out_len pointing at a JSON array of canonical events in linear memory, or 0 for "no events"
rustup target add wasm32-unknown-unknown       # once
cargo build --release --target wasm32-unknown-unknown
# → target/wasm32-unknown-unknown/release/<name>.wasm  (the reference demo is ~2.5 KB)
cargo test                                     # your pure parsing logic, on the host target

The reference crate sdk/wasm-guest-rust gives the alloc/decode exports over a static arena, a bounds-checked JSON writer with escaping, RFC 3339 from epoch seconds, and the discipline the conformance battery demands — strict parsing (a batch with any malformed frame decodes to nothing), oversized input refused, no panics reachable from input. #![no_std] + panic = "abort" + stock wasm32-unknown-unknown is what guarantees the module stays import-free. TinyGo (-target=wasm-unknown) or hand-authored wat work equally — the ABI is the contract, not the language.

desc := plugin.Descriptor{
    ID: "acme-ax", Version: "0.1.0",
    Schema: "iot.acme-ax/1",                       // the event schema you emit
    Tier: plugin.TierPartner,
    Capabilities: []plugin.Capability{plugin.CapDecode},
    Budget: plugin.Budget{},                       // zero = host ceilings apply
    KeyID: "quiver-partner",                       // resolved against the deployment KMS
}
err := desc.Sign(wasmBytes, kms)                   // fills ModuleDigest + Signature

host := plugin.NewHost(wasmrt.New(), kms)          // the wazero engine + the keyring
m := connector.PluginManifest(desc, wasmBytes, host,
    "Acme AX access controller", "Badge/door telemetry …", "docs …",
    defaultRules...)                               // optional bundled detect rules

Sign binds the signature to Canonical() — everything but the signature itself, including the module digest — so swapping either the descriptor or the module breaks verification. Who signs depends on tier; revoking the key revokes every plugin it signed. The plugin then rides the standard connector Manifest, indistinguishable from a built-in except that its decoder loads through the verify-then- sandbox path.

4.6 The MCP tool contract

POST /v1/mcp — one stateless streamable-HTTP door carrying single JSON-RPC 2.0 messages: initialize (version negotiation across 2024-11-05 · 2025-03-26 · 2025-06-18) · ping · tools/list · tools/call · resources/list · resources/read. Notifications → 202; batches refused; an unsupported MCP-Protocol-Version header → 400; GET/DELETE → 405 (no server stream, no session state).

Tool names carry the neutral platform_ prefix (2026-10-01ad, §13 Kind C of docs/reference/generic-nomenclature-program.md). Each shipped first as aether_<verb>; that spelling is still ACCEPTED on tools/call (an alias, never listed) until the owner's retirement date, so a client pinned to the old name keeps working while every listing host moves on its own.

MCP tool Backs onto RBAC
platform_ask /v1/ask (grounded, cited) canRead
platform_query store.Query JSON — the typed query, not SQL (ViewPage: masked, audited, cursored) canRead
platform_investigate worldline expansion canRead
platform_cases_list / get / annotate native IR cases (annotate = canAct, comment marked AI-channel) per role
platform_coverage framework coverage + gap map canRead
platform_posture CTEM / EASM / agentmesh posture reads canRead
platform_propose_action creates a conductor ActionRequest — never executes; returns the Hold/gate verdict canAct
platform_report_run report render canRead
resources the detection-as-code corpus (aether://detections/<id>) + the docs corpus when a static root is configured (aether://docs/<name>.md, traversal-proof) canRead

tools/list is role-FILTERED discovery; an out-of-role call returns an isError result plus a PEPAPIRBAC denial. Every tools/call and resources/read lands one canonical it/ai/tool-call (assembly.RecordMCPToolCall — subject = caller, app eventlake-mcp (aether-mcp before 2026-10-02r), redacted + capped arguments + sha256, allowed flag, latency), the SAME stream decode/genai normalizes.

4.7 Environment variables owned by this volume

Variable Surface Meaning
EVENTLAKE_API_KEY · EVENTLAKE_API_ADDR REST fixed admin key (else a dev key is minted and printed) · listen address (default :8080)
EVENTLAKE_GRPC_ADDR gRPC listen address; unset = gRPC off
EVENTLAKE_GRPC_TLS_CERT · EVENTLAKE_GRPC_TLS_KEY gRPC a real certificate pair instead of the dev self-signed one
EVENTLAKE_LLM_PROVIDER · EVENTLAKE_LLM_URL · EVENTLAKE_LLM_MODEL · EVENTLAKE_LLM_TIMEOUT GenAI the assistant.LLM adapter; unset ⇒ extractive
EVENTLAKE_LLM_ALLOW_HOSTED (and _MODEL_CANDIDATE_/_EMBED_ twins) GenAI 1 lets a self-managed node use a model endpoint outside its network (not loopback/private; a DNS name counts); without it such a URL fails the boot, and a hosted cell refuses one outright (owner §6lp, 2026-10-10t)
EVENTLAKE_LLM_KEY_FILE GenAI a file path, never the key itself in env or args (the no-secrets-in-code rule)
EVENTLAKE_LLM_UNMASKED_EGRESS GenAI the explicit opt-out from masking-before-egress (ForceMaskEgress overrides it toward masking)
EVENTLAKE_EMBED_* GenAI the vector.Embedder adapter; unset ⇒ HashEmbedder, the air-gap default
EVENTLAKE_MODEL_CANDIDATE_* GenAI the shadow-canary candidate the P4 pipeline compares against the incumbent
EVENTLAKE_DLP_SALT REST required before a NEW DLP dataset can be minted (422 if unset)

4.8 The canonical Event JSON — the one shape every door speaks

Classification is nested, not top-level. Every producer (a REST POST /v1/ingest body, a WASM plugin's emitted array, a decoder, an MCP-normalized agent event) emits:

{
  "time": "2026-07-26T10:15:00Z",
  "class": { "domain": "it", "category": "authentication", "type": "mfa.auth", "activity": "allow" },
  "severity": 2,
  "outcome": "success",
  "subject": { "kind": "user", "ref": "alice" },
  "object":  { "kind": "host", "ref": "db-1" },
  "src": { "ip": "10.1.2.3" },
  "dst": { "ip": "10.4.5.6" },
  "attributes": { "…": "…" },
  "labels": { "…": "…" },
  "schema": "…"
}

severity is numeric 0–4; time is RFC 3339; outcome ∈ success | failure | blocked | unknown. A plugin emits only what it parsed — observer is overwritten by the host and ingest-time fields are stamped downstream, so provenance cannot be forged. POST /v1/ingest takes the wrapper {"events":[…]}; a bare event object returns accepted:0.

4.9 Worked example — the action gate

# analyst proposes isolating a crown-jewel host
POST /v1/actions  {"kind":"isolate-host","target":"host:crown-1","severity":"high",
                   "confidence":0.95,"reversible":true,"crown_jewel":true}
→ 200 {"Status":"awaiting-approval","RequestID":"…"}    # the conductor HELD it

GET  /v1/actions/pending            (admin)  → the held request
POST /v1/actions/{RequestID}/approve (admin) → 200 {"Status":"executed"}

# later: the operator releases the isolation — the undo goes THROUGH the gate.
# The SAME chain runs, so the crown-jewel target holds the UNDO for approval too;
# a plain reversible release (an edge quarantine-file) executes straight through.
POST /v1/actions  {"kind":"isolate-host","target":"host:crown-1","reversible":true,
                   "confidence":1,"compensates":"<RequestID>"}
→ 200 {"Status":"awaiting-approval"}                  # …approve it as above, then:
→ 200 {"Status":"rolled-back"}     # actuation routed to Rollback; the mark is released
# a second release: {"Status":"denied"} — nothing left to compensate

A different tenant's admin sees neither the pending action nor can approve it (404).

4.10 Run it

go run ./cmd/eventlake-api        # mints + prints a dev admin key, listens :8080
curl -H "Authorization: Bearer <key>" http://localhost:8080/v1/healthz

The production shape — durable lake, TLS-termination topology per plane, systemd/container/k8s artifacts, backup/restore, HA — is 05-infrastructure-and-operations.md#deployment.


5. Proof

Honest-status language throughout: BUILT/SHIPPED means in the tree and tested; an out-of-repo smoke is named as such rather than implied.

5.1 The GenAI/agentic build order — each phase independently shippable and proven

Phase Scope Proof
P0 — Ollama adapter ✅ SHIPPED 2026-07-11 conduit/llmrt (Ollama + config) · EVENTLAKE_LLM_* wiring in cmd/eventlake-api · masking-on-egress · self-telemetry (it/ai/llm-request + the it/assist/answer/action-proposed audit trail, extractive path included) Stub-server round-trip · fallback-to-extractive on error/timeout (TestPlatformAskLLMFallbackExtractive) · key redaction (errors + an event-JSON scan) · masked prompt (TestMaskedEgress) · the spine E2E TestPlatformAskLLMTelemetry: ask → llm-request telemetry → a REAL llmsec prompt-injection finding on the copilot's own traffic
P1 — Embeddings + RAG ✅ SHIPPED 2026-07-11 llmrt.OllamaEmbedder behind vector.Embedder · the platform knowledge RAG index (assembly/knowledge.go) · assembly.WithVectorEmbedder · EVENTLAKE_EMBED_* Recall quality vs the HashEmbedder baseline through the real adapter (assembly.TestSemanticRecallBeatsHashBaseline — a zero-token-overlap paraphrase recalls decisively semantic-side, honestly not hash-side) · mixed-dim refusal (vector.TestIndexMixedDimRefusal, llmrt.TestEmbedMixedDimResponse) · air-gap default preserved (TestPlatformKnowledgeSearch/TestPlatformAskKnowledge run embedder-less; TestEmbedderFromEnv) · masked egress + key redaction (TestEmbedMaskedEgress, TestEmbedErrorPaths)
P2 — MCP server ✅ SHIPPED 2026-07-11 clean-room stdlib MCP over streamable HTTP (POST /v1/mcp) · per-tool RBAC + role-filtered discovery · every call → canonical it/ai/tool-call · platform_propose_action terminates at the gate via actions.Propose Protocol conformance (TestMCPProtocolConformance — negotiation · notifications-202 · batch/parse/method/tool error codes · 405 transport rules) · RBAC + tenant isolation per tool and telemetry canon (TestMCPToolsTenantIsolationAndTelemetry) · gate termination + human release + kill-switch deny (TestMCPProposeActionTerminatesAtGate, actions.TestProposeNeverExecutes) · resources incl. the traversal-proof docs corpus (TestMCPResources). Out-of-repo smoke remaining: a manual Claude Desktop/Code drive
P3 — Agent runtime + MCP client ✅ SHIPPED 2026-07-11 conduit/agentrt (declared allowlists, hard budgets, three closed tool classes, full it/ai telemetry; LLMPlanner + air-gap ScriptPlanner) · the stdlib MCP CLIENT (arg-masking, key-scrub, catalog-hash pin) · Rules-pack MCP connector manifests · A1/A2 wired in assembly Budget hard stops on all three legs (agentrt.TestBudgetHardStops) · scope-violation self-detection through the real platform (assembly.TestPlatformAgentScopeViolationSelfDetection — agentsec fires ASI-T03 on our own out-of-allowlist call → alert) · gated-action-only (TestPlatformAgentGatedActionOnly) · llmsec scan of a poisoned MCP response (TestPlatformAgentUntrustedResponseScanned — withheld from the prompt, mesh-injection on the observation hop) · MCP round-trip/attestation/masked-egress/key-scrub/actuator legs (agentrt.TestMCP*, TestMCPToolsHashPin, TestMCPActuatorLegs, TestMCPKeyScrub) · air-gap default (TestPlatformAgentAirGapDefault). Out-of-repo smoke remaining: a live external MCP server to dial
P4 — Own-model pilot ✅ SHIPPED 2026-07-11 (in-repo pipeline; training/serving out-of-repo) conduit/modelpilot (Cut the masked+consented corpus · Evaluate the four gates · Registry+Compare/Canary + the signed Artifact) · wired in assembly (the Compiler-governed lifecycle, D22-durable) · /v1/model/* · EVENTLAKE_MODEL_CANDIDATE_* Corpus mask + consent through the real ledger (assembly.TestPlatformModelCorpusMaskedAndConsentGated — a non-consenting tenant contributes nothing, a PAN never reaches a pair) · the gates BLOCK a bad model and PASS a good one, grading watched as it/ai/llm-request (TestPlatformEvaluateBlocksBadModel) · shadow-canary recommends a good candidate over a weak incumbent (TestPlatformCanaryRecommendsGoodCandidate) · promotion durable across restart + at-most-one-active demotion (TestPlatformModelPromotionDurableAcrossReplay) + the modelpilot.* unit suites. The GPU training run and a served candidate endpoint are the out-of-repo smoke

5.2 The agent roster's closed-loop proofs

Each authored-content agent is proven end to end — draft → gate → human release → the artifact really works → the Compensates undo:

5.3 Transport and extensibility proofs


6. Deferred, gated, and known gaps

Recorded honestly; none is implied to be built.

REST plane

Query surface

Plugins

GenAI / agentic / MCP

Protocols

Reference material


7. Provenance

Former doc Lines Absorbed into
genai-agentic-integration.md 855 §2.5 (all subsections) · §4.6 MCP contract · §4.7 env vars · §5.1 build order · §5.2 agent proofs · §6
api-management-plane.md 486 §2.1 · §4.1 endpoint catalogue (verbatim) · §4.2 parameters/OCSF/pagination · §4.8 event shape · §4.9 worked example · §4.10 run it · §6 deferrals
protocol-integration-strategy.md 380 §2.6 · §1.3(c) the extension flow · §3 (D08-9, D08-11, D08-12) · §6
protocol-decision-records.md 255 §2.7 (Parts A–E) · §3 (D08-25, D08-26) · §6
grpc-surface.md 218 §2.2 · §4.3 service catalogue + status mapping · §3 (D08-1, D08-21, D08-22) · §5.3
plugin-authoring-guide.md 164 §2.4 · §4.5 ABI/descriptor/packaging · §3 (D08-10) · §5.3 · §6
query-surface.md 110 §2.3 · §4.4 grammar + response · §3 (D08-2, D08-3, D08-4) · §6
integration-map.md 99 §2.8 · §3 (D08-30, D08-31)

Sibling volumes: 00-charter-and-strategy.md (the AGI doctrine's invariants and autonomy ladder, the roadmap) · 01-substrate-plane.md (the event model, the lake, the decoders) · 02-analytics-plane.md (llmsec/aispm/agentsec, detection content) · 03-execution-plane.md (the conductor chain, SOAR, Validation, CTEM) · 04-identity-and-policy-plane.md (identity, the clearance ladder and compartment semantics this volume enforces, zero-trust) · 05-infrastructure-and-operations.md (deployment, sizing, TLS topology) · 06-governance-and-trust.md (compliance, AI conformity, the trust center's policy, supply chain) · 07-commercial-and-gtm.md (licensing and metering policy, service tiers).