# Homarr Link contract ## Supported upstream The compiled definition uses `ghcr.io/homarr-labs/homarr:latest`. The compatibility gate requires API version `>= 2.4.1`, read through authenticated `info.getInfo`. Stable versions are compared numerically by major, minor and patch; newer releases do not need a separate allowlist entry. Setup and every Link reject older or malformed versions with a recovery hint. `integration.getKinds` must expose the descriptor's kind and the exact supplied credential alternative. Missing credentials name the peer's Setup command; they are not submitted as empty keys. Earlier on 2026-10-13, stable [v2.5.1](https://github.com/homarr-labs/homarr/releases/tag/v2.5.1) was published at release source `c2e95f8d05b87dae42e9cd638dd7d4822b7cd608`, including [hotfix #7170](https://github.com/homarr-labs/homarr/pull/7170) merged as `62c8513a27e9fd40ad935fac152ba1a6a84f0867`. Its observed published `:latest` index was `sha256:92520b5b5188b1ea54cfa19dc2e6d54c7f09d21aca6b2868355f0b4985d70d71`. The scoped source audit from the historical resolved `2.4.1` source through `2.5.0` and that hotfix found the relevant API, credential, native-widget, Stats and Downloads contracts unchanged except the specific hotfix corrections; no client protocol adaptation is needed. The fresh `2.5.1` image check observed authenticated API `2.5.1` at that index and OCI revision `62c8513a27e9fd40ad935fac152ba1a6a84f0867`. Later on 2026-10-13, stable [v2.5.2](https://github.com/homarr-labs/homarr/releases/tag/v2.5.2) was published at source `7eb9a417a60d759adce5f7ac15ceb594aefad471`. Its source comparison against the validated `2.5.1` OCI revision changes only the changelog, root version and Beszel chart CSS. API, authentication, Stats, Downloads and all 39 native peer implementations are unchanged; the ten privacy owner source hashes match. The fresh published `2.5.2` image check verified its authenticated API version, image index and OCI revision. These audits record specific checked contracts rather than a fixed global supported-version list. These source comparisons and narrow API observations do not establish peer-specific native/privacy, browser or lifecycle acceptance; peer-specific privacy holds remain in place. ### Historical 2.4.1 image proof The historical proof observed API `2.4.1` through authenticated `info.getInfo`. Its tagged research baseline is `ebbd4876d25a51151444a009ab596e1154d62880`; it is not the resolved image revision. The inspected image index is `sha256:ee67a5e856c051c4e114418a54b89a3fc3cb5507d0689e5cb89c790305c549cd`, its linux/arm64 manifest is `sha256:308feba8b9845cbdb21eb2e1b05579d49fb815bf3f0a452e58b59fcd4db8ecd2`, and its local image ID is `sha256:c6d895d659718b05ab9cc7b936458e6f5820b5cf3f0d35318588bd1b6901ee1b`. Its OCI revision label is `3d4150e59f600876b7e308f857b2f21572b75348` and version label is `main`, neither a substitute for the observed API version. A release tag is mutable; proofs record each identity separately. The supported operations are the existing REST Apps/boards/item creation, tRPC `integration.all/create/update/delete`, `board.getBoardByName/saveBoard`, and per-App native queries. Successful connection creation requires a returned integration ID and no body error, not merely HTTP 200. Widget options and metric keys were checked at both source revisions. Scoped comparisons found the common integration definitions/registry/router, widget map/router, Stats providers and refresh/cache handlers, Immich adapter/router/request handler/widget options, and App validation byte-identical. This is not a claim that the revisions are equivalent in full, nor proof of another peer's native data. API readback alone is not rendered acceptance. ## Peer worker seam Each peer owns `internal/apps/homarr/target_.go`, defining `Target() target` in package `homarr`. Reuse `target`, `secret`, `widget`, `fixed`, `plain`, `statWidget` and the existing widget `bind` callback. `internal/apps/homarr/targets.go` is the only registry assembly, owned by the reference lane/orchestrator. Peer lanes do not edit it or common reconciliation. The existing nine descriptors remain in `existingTargets()`. A descriptor supplies Catalog ID/name, upstream integration kind, internal Compose service Address, credential resolver and useful widgets. Its optional `preflight(context.Context, from, to *helpers.H) error` runs first in both `linked` and `add`, before compatibility requests or any board, tile or integration mutation. It must probe afresh, remain read-only, and return `helpers.ErrManual` for a person-owned prerequisite. Emit the specific recovery instructions through `from.Info` before returning Manual; the step retains its normal descriptor hint. Nil preserves existing behavior. Removal and absence checks never call it, so unavailable credentials or prerequisites cannot block owned cleanup. It is not an Address resolver or a TLS bypass. Privacy-sensitive leaf preflights can call `requireCredentialSafeHomarr(ctx, from, hint)` before provider requests or mutation. The helper performs one authenticated `info.getInfo` read and admits the verified safe version `2.5.1` and any later `2.x` release. Older, unknown or malformed versions and failed reads emit only the caller's fixed App-specific recovery hint through `from.Info`, then return `helpers.ErrManual`; no provider request, mutation, cache or raw response/error output is added. Normal Apps retain the existing `supportedVersion` minimum-version policy and failure behavior. The shared helper alone is not leaf acceptance: real consumers, focused leaf checks and any remaining native/privacy or outside runtime prerequisites must be accounted for. The optional `verify(context.Context, from, to *helpers.H, integrationID string) error` probes authenticated useful data; verification must fail on missing fields or authentication failures and accept legitimate zero values. Use a real identifier in `bind` when needed, or leave that widget out. Do not invent an album, entity, website or machine. `stats` and `calendar` remain shared, with per-integration ownership and deterministic per-integration metric entry IDs. For non-nil verifiers with Stats metrics, common `verifyData` additionally calls `freshStatsSnapshot(ctx context.Context, h *helpers.H, integrationID string) (map[string]any, error)` in both Check and apply. It reads `widget.stats.snapshot`, requests `widget.stats.refresh` with `force: true`, and requires `error: false`, `stale: false`, a positive `updatedAt` advancing from the prior timestamp, and non-null values for every descriptor metric. It returns the refreshed values. Homarr's refresh can no-op on lock contention or queue timeout while a cached snapshot remains non-stale for an hour; neither HTTP success nor `stale: false` alone is acceptance. No-op refreshes fail with a Setup retry hint; the helper does not loop. The existing Step boundary may run Check followed by apply. Nil verifiers keep legacy behavior; removal never refreshes Stats. Peers keep their own semantic/destination checks without editing the common guard or widening its signature. Migrated descriptors additionally supply `stats func(statsSample) error` for Stats semantics. Their existing `verify` keeps native identity and useful-data checks and no longer acquires Stats. After that verifier succeeds, common `verifyData` deduplicates selected metric keys across Stats widgets, reads the authenticated catalog and obtains one completed refresh. It rejects missing catalog keys, malformed or duplicate catalog entries, missing/null selected values, non-finite or nonpositive timestamps and refreshes that do not advance. The callback receives that same sample's `values`, catalog `units`, raw `retryAt`, `startedAt`, `completedAt` and `updatedAt` (Unix milliseconds), after these checks. `retryAt` comes from the refresh response without coercion; missing/null metadata remains nil, and malformed or nonzero metadata stays available for App-specific validation. Common checks impose no retry policy and perform no extra read. `startedAt` is read before the refresh request and `completedAt` after its response, so leaves can retain their existing future-skew limits after a slow refresh. Common checks only timestamp finiteness, positivity and advancement; Apps own clock policy. Leaves retain expected units, integral counts, ranges, nullable-field rules, additional clock limits and relationships. Explicit zeros remain valid where the App allows them. Mutable native-widget results are separate cached samples; do not require equality between independently collected volatile values. One refresh applies per migrated verification invocation. The Step can run Check before and after apply, so a successful first Link can collect more than one sample across distinct invocations. Samples are never remembered across invocations. Descriptors without `stats` keep their current verification guard without a new catalog request; nil `verify` still skips data verification. Migrate each peer by retaining its native checks and moving its Stats checks into `stats`. Preflight/Manual ordering and removal without Stats requests stay unchanged. Downloads peers may use `readDownloadsQueue(ctx, from, integrationID, kind)`. This package-private module owns the existing authenticated read with a requested limit of 50, exactly one result, its nil error field, matching outer/nested integration IDs and kind, object data/status and an explicit items array. An empty array is valid. It returns unchanged integration metadata, status and items, and fixed recovery errors without raw response details. Apps retain readiness/provider checks, response-count limits, rates, queue types, item states, numeric ranges, nullable speeds and finite negative time rules. The module does not acquire Stats or interpret queue meaning. No-auth kinds may omit the credential resolver: the common transport sends an explicit empty secrets array and validates it against the upstream empty alternative. Frigate is not a no-auth kind: the supported Homarr versions need administrator username/password over trusted HTTPS. The Catalog's authenticated HTTP starter cannot meet that contract; its peer-owned preflight must stop as Manual before mutation, not submit an empty credential alternative or disable TLS verification. Credentials belong to the peer's Setup and are saved with `h.SetSecret`. Use `h.Secret`, `h.Key`, shared Admin login and existing helper interfaces; do not read files or query databases from a Homarr descriptor. Dedicated scoped keys are preferred where supported. A browser-only credential boundary is a Manual step, not permission to inject database keys or fake acceptance. ## Ownership and recovery Only the integration ID recorded in `HOMARR_LINKED_` can be updated or removed. A matching user integration (same kind/Address) stays untouched; a new Link creates a separate owned integration and records Homarr's returned ID. New integration widget ownership starts with `{}`; existing user widgets are not inferred as owned. Only recorded widget IDs are removed. Shared widgets lose just the removed integration's entries/binding. If any remaining widget on any visible board still uses the integration, removal preserves that integration and releases the Link's recorded ownership, rather than destroying a user widget's connection. A later Link creates its own integration again; it does not reclaim the preserved record. Its linked Homarr App record is preserved too. Its generated or empty description becomes `self-host retained tile:`; an existing custom description is kept. The retained marker is not an owned marker and legacy navigation migration skips it, so even the original nine descriptors cannot reclaim it on reinstall. The recorded App and Link ownership are removed; Homarr cannot delete an App still linked to an integration. If the user edited its ping Address, release uses the recorded App ID rather than an Address match and preserves its other fields. Only the managed Home tile placement is removed. Legitimate legacy navigation migration is otherwise unchanged. Existing recorded Links retain their conservative legacy widget migration: only a complete unique generated set with deterministic Stats IDs is eligible; ambiguous sets stop for an explicit ownership decision. Missing ownership is not permission to delete matching records. Existing legacy navigation-tile migration applies only to the original nine descriptors; new peers require a `self-host tile:` marker or a recorded App ID. Browser Addresses remain separate from internal integration Addresses. The service owns absent/unready peer scheduling; the absent-peer fix is #265. It must land with this work: absent peers do not run; installed unready peers retain the actionable wait. Adding a descriptor does not bypass readiness. ## Immich reference Immich v3.2.2 uses a dedicated administrator-owned key named `self-host Homarr statistics`, stored as `IMMICH_HOMARR_API_KEY` and its identifier as `IMMICH_HOMARR_API_KEY_ID`. Exactly `user.read` and `server.statistics` are needed by Homarr's connection test and server-stat queries. No write, album, asset-download or all-permissions scope is granted. The official guide lists broader scopes for album features; those features are not enabled by this Link. Setup validates the key's permission metadata, administrator identity, statistics and user list. A valid key is reused. A lost/rejected secret with an existing managed key stops as Manual instead of rotating or adopting it. Restore the secret through the Project boundary, or delete only that managed key in Immich's user settings and retry `self-host setup immich`. The Link uses `http://immich:2283`, verifies authenticated `widget.immich.getServerStats`, and adds `immich-serverStats` plus shared Stats entries `userCount`, `photoCount`, `videoCount`, `totalLibraryUsageInBytes`. Its common guard also requires a genuinely refreshed Stats snapshot, so cached native server statistics alone cannot complete the Link. An empty library is valid when authenticated statistics explicitly report zeros and the user list is nonempty. No album widget or fabricated album ID is added. The [real API/browser proof](homarr-immich-proof.md) records its source, resolved image identities, rendered values and ownership lifecycle, with explicit gaps. At production `89301aff17c86b767f5a45076881f2bb7b41db39`, the updated attempt verified the freshness guard and edited-ping Immich lifecycle. The final bounded attempt additionally passed retained original-descriptor exclusion through two reinstall/remove cycles and post-lifecycle preserved-board/Home rendering, with reviewed native/shared statistics and the preserved user metric. The legacy record is ownership-only, not native ntfy/Sonarr acceptance. Prior partial evidence is retained separately. PR #309 stays draft pending both parent-owned independent Standards/Spec reviews; this is not a contract freeze or permission to register peers. Populated media, Manual key recovery, migration/restore, physical LAN, actual refresh contention and non-nil Frigate prerequisites remain proof gaps. Sources: - [Immich integration guide](https://homarr.dev/docs/integrations/immich/) - [Homarr Immich adapter](https://github.com/homarr-labs/homarr/blob/ebbd4876d25a51151444a009ab596e1154d62880/packages/integrations/src/immich/immich-integration.ts) - [Homarr integration operations](https://github.com/homarr-labs/homarr/blob/ebbd4876d25a51151444a009ab596e1154d62880/packages/api/src/router/integration/integration-router.ts) - [Homarr widget options](https://github.com/homarr-labs/homarr/blob/ebbd4876d25a51151444a009ab596e1154d62880/packages/widgets/src/immich/server-stats/index.ts) - [Immich key API](https://github.com/immich-app/immich/blob/v3.2.2/server/src/controllers/api-key.controller.ts) - [Homarr Frigate Stats authentication](https://github.com/homarr-labs/homarr/blob/ebbd4876d25a51151444a009ab596e1154d62880/packages/integrations/src/stats/providers/frigate.ts) - [Resolved-image Stats refresh/cache contract](https://github.com/homarr-labs/homarr/blob/3d4150e59f600876b7e308f857b2f21572b75348/packages/request-handler/src/stats.ts) - [Resolved-image Stats API](https://github.com/homarr-labs/homarr/blob/3d4150e59f600876b7e308f857b2f21572b75348/packages/api/src/router/widgets/stats.ts)