Appearance
Hooks HTTP API
This is the reference for the hooks HTTP surfaces. It maps the ListHooks operation and the opt-in managed-push family to their OpenAPI schemas, ReBAC gates, audit emissions, and the closed Problem.code taxonomy. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract.
The /v1/hooks listing surface is strictly read-only / discovery-only. Each row is a projection of an upstream Kubernetes PlexdHook custom resource that a cluster-hosted plexd agent observed and advertised to the control plane — see ../../../internal/policy/hooks for the one-way projection contract. On this listing surface plexsphere records what the agent observed and reports an image_digest_match verdict; it never writes PlexdHook objects back to the cluster, never resolves the digest against a registry, and exposes no create / patch / delete affordance. A reader expecting a write seam on /v1/hooks is looking at the wrong surface — the cluster is the source of truth for hook objects and this catalog only mirrors them.
The opt-in managed-push family under /v1/domains/{domainId}/managed-push is a separate, per-Domain authoritative-write surface. When a Domain enables managed push it attaches a sealed kubeconfig and plexsphere becomes the source of truth for the PlexdHook objects it publishes into that customer cluster via Server-Side Apply, recording a one-shot-rollbackable trail per push. The family is fail-closed: a Domain that has not opted in (or a deployment that has not provisioned the sealing key) leaves every operation on its 501 managed_push_not_provisioned stub. The full treatment — the ubiquitous language, the threat model, the sealing contract, the SSA-authority model, and the exhaustive Problem-code catalogue — lives in the managed-push bounded-context reference.
The cursor + limit pagination idiom and the per-row visibility filter are inherited from the sibling Fleet Nodes surface — see ./nodes.md for the design rationale this surface mirrors.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| GET | /v1/hooks | ListHooks | top-level platform:plexsphere#read + per-row domain#read filter | discovered_hook.list (page-level row) | (none) | n/a |
| GET | /v1/domains/{domainId}/managed-push | GetManagedPush | domain#read | managed_push.target.read | (none) | n/a |
| PUT | /v1/domains/{domainId}/managed-push | PutManagedPush | domain#manage | managed_push.attach | (none) | 64 KiB |
| DELETE | /v1/domains/{domainId}/managed-push | DeleteManagedPush | domain#manage | managed_push.detach | (none) | n/a |
| POST | /v1/domains/{domainId}/managed-push/hooks | PushManagedHook | domain#manage | managed_push.push | (none) | 64 KiB |
| GET | /v1/domains/{domainId}/managed-push/hooks/{pushId} | GetManagedHookPush | domain#read | managed_push.push.read | (none) | n/a |
| POST | /v1/domains/{domainId}/managed-push/hooks/{pushId}:rollback | RollbackManagedHookPush | domain#manage | managed_push.rollback | (none) | n/a |
The managed-push family emits no outbox event — its audit trail is the canonical record of every attach / detach / push / rollback. The read / manage ReBAC gates reuse the permissions the tenancy schema declares on the domain definition (the same gates GetDomain and DeleteDomain authorise against). The 64 KiB body cap on PutManagedPush (the base64 kubeconfig) and PushManagedHook (the five-field PlexdHook spec) refuses the authenticated-DoS vector an unbounded body would expose; a kubeconfig over the cap surfaces as 413 kubeconfig_too_large.
ListHooks.limitquery parameter is clamped at the handler to[1, 200]with default50. Unlike the reject-on-out-of-range surfaces, an out-of-range value is silently brought into range so a paging client never breaks on a tuning mismatch.ListHooks.cursoris opaque and HMAC-signed by the server, bound to the per-(caller, pepper)pseudonym (the codec is theCursorCodecport wired at the composition root). A tampered or malformed cursor surfaces as400 invalid_cursor; a cursor minted by one caller and replayed by another surfaces as403 cursor_binding_mismatch. A nil codec is tolerated in unit-test composition roots and behaves as an identity passthrough.ListHooks.domain_id,ListHooks.project_id, andListHooks.image_digest_matchare optional filters. Omitting a filter widens the page along that axis.- The handler ships behind a fail-closed scaffold gate — until the composition root wires both the
HookListerservice and theAuthorizer, the listing surface returns501 hooks_not_provisionedso the surface is either fully wired or fully off.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| ListHooks | domain_id (query) | string (uuid) | no | Optional owning-Domain filter. When present, only hooks on the named Domain are returned. |
| ListHooks | project_id (query) | string (uuid) | no | Optional Project filter. When present, only hooks on Nodes in the named Project are returned. |
| ListHooks | image_digest_match (query) | boolean | no | Optional drift filter. true → only hooks whose discovered digest matches the declared digest; false → only mismatches. Omitted keeps both. |
| ListHooks | cursor (query) | string | no | Opaque HMAC-signed continuation from a previous call's next_cursor. Tampered or malformed → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch. |
| ListHooks | limit (query) | integer | no | [1, 200], default 50. Out-of-range values are clamped, not rejected. |
Schemas
DiscoveredHook
One discovered Kubernetes hook custom resource — a read-only discovery projection per Node: what the agent observed in its cluster, with a flag for whether the discovered image digest matches the declared one. The shape is returned by ListHooks.
| Field | Type | Required | Notes |
|---|---|---|---|
node_id | string (uuid) | yes | Node the hook was discovered on (UUIDv7). |
domain_id | string (uuid) | yes | Owning Domain (UUIDv7) — the residency pivot the per-row visibility filter authorises against. |
name | string | yes | Discovered hook resource name. |
image_digest | string | yes | OCI image digest of the discovered hook image in canonical sha256:<64 lowercase hex> form. |
image_digest_match | boolean | yes | true when the discovered image digest matches the digest declared in the Node's capability manifest, false when it diverges. |
HookList
Page of discovered hooks returned by GET /v1/hooks. The window is computed by the persistence layer in (node, hook-name) order; per-row visibility is layered on top, so the items array is the subset the caller is authorised to see — len(items) < limit is NOT a reliable end-of-stream signal. Consult next_cursor instead. The continuation token is derived from the persistence-layer page regardless of how many rows the per-row filter dropped, so a fully-filtered page still hands the caller a token to keep paging.
| Field | Type | Required | Notes |
|---|---|---|---|
items | array<DiscoveredHook> | yes | Discovered hooks in the current page (post per-row visibility filter). |
next_cursor | string (nullable) | no | Opaque HMAC-signed continuation. Absent or null at end-of-stream. A tampered cursor surfaces as 400 on the next call. |
ReBAC contract
ListHooks runs two gates. A top-level platform read check authorises entry to the surface and runs before the persistence read so an unauthorised caller never observes the existence side-channel of the discovered-hook set. A per-row domain#read check then authorises each returned row — the BOLA guard that prevents a platform-reader from seeing hooks on a Domain they cannot read.
| Operation | Relation evaluated | Subject | Object | On denial |
|---|---|---|---|---|
| ListHooks | top-level read | resolved principal projected onto the SpiceDB subject string | platform:plexsphere | 403 PermissionDenied (separate schema); a denial audit row is written first with outcome permission_denied |
| ListHooks | per-row read | resolved principal projected onto the SpiceDB subject string | domain:<uuid> for each candidate row | row filtered out; per-row denial NOT audited individually (page-level audit row carries the cohort count and a discovered_hook.list.authz_errors counter) |
Both gates go through the narrow Authorizer port (declared in ../../../internal/transport/http/v1/hooks/wiring.go), which takes a canonical (subject, relation, object, caveatCtx) tuple. The handler projects the resolved authn.Principal onto the SpiceDB subject string at the boundary. The application service stays bounded-context-pure (it knows nothing about ReBAC); the per-row filter is a transport-layer concern, mirroring the Nodes listing handler.
Per-row authz failure mode is fail-closed with observability: ErrPermissionDenied (or any error wrapping it) drops the row silently; any OTHER error class drops the row, emits a slog.WarnContext breadcrumb, and increments the audit row's discovered_hook.list.authz_errors counter so an operator can see drift between the persistence-layer page and the post-filter response.
Error taxonomy
The closed Problem.code set this surface emits, exactly as defined in ../../../internal/transport/http/v1/hooks/errors.go. The Origin column names the layer (handler / service / transport) so a future maintainer knows where to grep when the code changes.
| HTTP status | Problem.code | Origin | Trigger |
|---|---|---|---|
| 400 | invalid_cursor | handler | List cursor failed CursorCodec.Decode and was not a binding mismatch. |
| 401 | unauthenticated | handler | No resolved principal (or authn.KindUnknown). |
| 403 | (PermissionDenied) | transport | Top-level platform:plexsphere#read denial (separate PermissionDenied schema, not Problem). Per-row denials never reach this gate — they are dropped from items instead. |
| 403 | cursor_binding_mismatch | handler | List cursor was minted for a different caller than the one presenting it (the per-(caller, pepper) HMAC binding rejected the replay). |
| 500 | internal | handler / service | HookLister.List returned a non-nil error, or CursorCodec.Encode failed on the next-page cursor. The underlying error text is logged via slog.ErrorContext and NEVER leaks to the wire. |
| 501 | hooks_not_provisioned | handler | Scaffold fail-closed gate: the HookLister service OR the Authorizer is not wired in this build. |
There is no out-of-range limit error code on this surface — the handler clamps rather than rejects.
Audit & outbox contract
ListHooks is a read-only surface, so it never writes an outbox event. The application read service emits no audit row of its own; the handler stamps exactly one page-level audit row per successful response via the wired AuditSink. Sink errors are NOT propagated to the caller — a flaky audit backend cannot turn a successful read into a 5xx, and the slog.WarnContext breadcrumb is the operator's tripwire.
| Operation | Outcome | Audit relation | Audit outcome | Outbox event |
|---|---|---|---|---|
| ListHooks | success | discovered_hook.list (page-level row) | granted | (none) |
| ListHooks | 403 platform-gate denial | discovered_hook.list | permission_denied | (none) |
| ListHooks | 401 / 400 / 500 / 501 | (none — those arms exit before the audit emission) | n/a | (none) |
The audit row stamps the canonical platform singleton (platform:plexsphere) as its Object because the list operation is cross-Domain and optionally cross-Project — there is no single aggregate to address. The caveat_context map carries:
count— the number of rows in the response after per-row visibility filtering, NOT the persistence-layer page size.persistence_count— present only when the per-row filter dropped at least one row; the pre-filter persistence-page size, so an auditor of a suspected over-broad cross-Domain read can see the drop ratio (a fully-filtered page would otherwise show onlycount=0).discovered_hook.list.authz_errors— present only when the per-row filter dropped at least one row due to an infrastructural fault (transport flake, schema drift). Absent when every drop was a cleanErrPermissionDenied.
The denial audit row written by the top-level platform gate carries a missing_relation caveat naming the relation the caller lacked.
Managed-push surface
The opt-in managed-push family is a per-Domain authoritative-write surface; this section is a map of its wire contract — the exhaustive catalogue lives in the managed-push bounded-context reference and the authoritative shapes in api/openapi/plexsphere-v1.yaml.
The four request / response schemas the family adds:
ManagedPushAttachRequest— thePutManagedPushbody: a base64-encoded kubeconfig plus theenabledopt-in flag. The Service validates and seals the kubeconfig; the plaintext never reaches an audit row or a log. Only embedded, in-band credentials are accepted — a token, orclient-certificate-data/client-key-data, with the CA inline ascertificate-authority-data. A kubeconfig carrying anexec/auth-providerplugin, a file-path credential (tokenFile,client-certificate,client-key), acertificate-authorityfile path, or aproxy-urlis rejected with422 kubeconfig_invalid; see the validation catalog in the managed-push context reference.ManagedPushTarget— theGetManagedPush/PutManagedPushresponse: a display-only projection (enabled, the kubeconfig SHA-256 fingerprint, the API-server URL, timestamps) that never carries the kubeconfig ciphertext or plaintext.ManagedHookPushRequest— thePushManagedHookbody: the five-field PlexdHook spec (name, image digest, parameters, timeout seconds, sandbox) plus the target namespace.ManagedHookPush— the trail record returned byPushManagedHook/GetManagedHookPush/RollbackManagedHookPush: the push id, hook name, namespace, status, ahad_prior_stateboolean, and the push / rollback timestamps. The captured prior cluster object is reduced to the boolean and is never projected onto the wire.
The closed Problem.code set the managed-push family emits, exactly as pinned in ../../../internal/transport/http/v1/managedpush/wiring.go:
| HTTP status | Problem.code | Trigger |
|---|---|---|
| 400 | invalid_domain_id / invalid_push_id / invalid_body | A path id or request body failed shape validation at the handler boundary. |
| 401 | unauthenticated | No resolved principal. |
| 403 | (PermissionDenied) | The per-operation domain#read / domain#manage gate denied the caller (separate PermissionDenied schema, not Problem). |
| 404 | managed_push_not_configured / domain_not_found / push_not_found | The Domain has no target, the addressed Domain does not exist, or no recorded push matches the id. |
| 409 | managed_push_disabled / managed_push_conflict / push_already_rolled_back | The target is disabled, a competing field manager owns a field the apply touches, or the push was already rolled back. |
| 413 | kubeconfig_too_large | PutManagedPush body exceeded the 64 KiB cap. |
| 422 | kubeconfig_invalid / plexd_hook_invalid | The attach-time kubeconfig or the push-time PlexdHook spec failed validation. |
| 502 | cluster_unreachable | The customer cluster could not be reached to apply or roll back. |
| 500 | internal | An unexpected error; the underlying text is logged, never surfaced. |
| 501 | managed_push_not_provisioned | Fail-closed opt-in: the Domain has not enabled managed push (or the deployment has not provisioned the sealing key), so the surface is fully off. |
Cross-references
../../contexts/hooks/managed-push.md— the opt-in managed-push bounded-context reference: the ubiquitous language, the sealing and SSA-authority contract, the one-shot rollback rule, the threat model, and the exhaustive Problem-code catalogue this section maps../nodes.md— sibling Fleet read surface that established the cursor + limit pagination pattern, the per-rowreadReBAC filter, and the page-level audit row contract this surface mirrors.../../contexts/identity/rebac.md— relation graph behind theplatform#readanddomain#readgates../index.md— platform-wide/v1HTTP surface map.../../../api/openapi/plexsphere-v1.yaml— authoritative OpenAPI 3.1 contract; this doc is a map, not a duplicate.../../../internal/policy/hooks— the one-way projection of an upstream KubernetesPlexdHookcustom resource onto the discovery-only spec; the read-only contract this surface exposes.../../../internal/transport/http/v1/hooks/list.go— handler body; platform gate, cursor decode, limit clamp, optional filters, per-row Domain visibility filter, page-level audit emission.../../../internal/transport/http/v1/hooks/wiring.go—HookLister,Authorizer,AuditSink, andCursorCodecport declarations plus the relation / object / limit constants.../../../internal/transport/http/v1/hooks/errors.go— the closedProblem.codetaxonomy, theErrPermissionDeniedandErrCursorBindingMismatchsentinels, and the RFC 9457 problem renderer.