- Status: Accepted
- Date: 2026-07-24
- Deciders: thulasi.ram (architect)
- Builds on: 0014 (secret scopes + inheritance),
0037 (scoped-secret resolution, effective-status matrix),
0046 (
ForgeConnection,Project= governed repo, Forgejo admin-registration), 0049 (Org/Project RBAC scope)
Context
Section titled “Context”Three governance concerns exist in the model and (for secrets) the API, but have no management surface, so they are unreachable in the product:
- Org-scoped secrets — the top of the
env → repo → orginheritance chain ([0014]/[0037])./v1/secretsalready supports org scope, but the web-uiRepoSecretstab is hardwired to{ org, repo }, so org secrets can only be set by raw HTTP. - Environment-scoped secret values — the Environments tab edits protection rules;
the
SecretMatrixis read-only. Nothing in the UI sets an env-scoped secret value, despite [0037] making env-scoped secrets a live-path feature. - Forge connections —
ForgeConnection+ForgeConnectionStore+ Postgres exist, but there is no HTTP API and the only productionput_connection/bind_repocaller is the GitHubinstallationwebhook (scarab-forge-github/src/lib.rs:301). A Project is aforge_reposbinding — there is noprojectstable ([0046], migration 0022/0023), so “create a Project” isbind_repo. Consequently Forgejo is unusable: no connection- creation path, no repo binding, no webhook registration — its only route into the DB is a hand-written row. [0046] shipped Forgejo “in v1”; in practice it cannot be onboarded.
A prior decision fixes the tenancy framing: there is exactly one Org (single-tenant
experience), but Org stays the model’s RBAC/secret inheritance root ([0049]/[0037]) — we
do not collapse the concept, we just never build org navigation. “Org settings” is
therefore the settings of the one org, surfaced as a single global Settings area.
Decision
Section titled “Decision”A. A single global Settings area (org-scoped), Administer-gated
Section titled “A. A single global Settings area (org-scoped), Administer-gated”A new top-level /settings route holds the org-scoped surfaces: Connections and Org
Secrets. It is enforced at Administer on the Org ([0049]) and the nav entry is hidden
from non-admins (nothing there is actionable or informative for them). There is no org
switcher — one implicit Org.
B. Secret editing is a unified, scope-dimensioned surface; the matrix is the editor
Section titled “B. Secret editing is a unified, scope-dimensioned surface; the matrix is the editor”- Org secrets live in global Settings (org scope, the inheritance root).
- Repo + Environment secret values live in the Project’s Secrets tab, where the
[0037] effective-status matrix becomes editable: rows are keys, columns are
Repository default(the Project/SecretScope::Reposcope) + one per Environment. Editing a cell writes a value at that scope; the cell always distinguishes set-here / inherited / unset (+ the [0037] “intentionally unset” marker), so an edit reads as “override at this scope” vs “fall through.” - The Environments tab stays rules-only (approvers, allowed refs, timers). Secrets are one key-addressed namespace resolved by scope ([0037] §C) — they are not fragmented into per-Environment editors.
- User-facing copy says “repo” (the end-user abstraction); the scope resolves against the
Project (governance = repo + its Environment link).
SecretScope::Repois not renamed — internal, user-invisible, and a rename is broad churn for no correctness gain.
C. Connection lifecycle: full onboarding, asymmetric by forge
Section titled “C. Connection lifecycle: full onboarding, asymmetric by forge”A new /v1/connections surface (list / create / delete) plus bind_repo / unbind_repo /
register_webhook, because for any forge without installation-style auto-registration the
binding step is the missing repo→Project onboarding flow.
- GitHub is observe-only. Installing the App on GitHub is registration; there is no API
for Scarab to install/uninstall or change repo selection. The UI shows the installation,
its bound Projects, and credential/delivery health; offers a “Manage on GitHub →” deep
link and a re-sync action (re-fetch
installation_repositoriesto heal a missed webhook). Auto-bind is untouched. - Forgejo is fully in-product. Admin creates a connection (
base_url+ credential), binds repos (creating Projects), and registers per-repo webhooks ([0046]register_webhook).
D. A connection has exactly one owner: config or DB — never both
Section titled “D. A connection has exactly one owner: config or DB — never both”Connections may be provisioned two ways, and each connection is owned by exactly one source:
- IaC / declarative — a
connections:config block (Helm values → server config), each{ kind, base_url, credential: <env-var | secret-ref> }(+ optional repos to bind). Provisioned at boot, authoritative, and read-only in the UI (labelled “managed by configuration”). This generalizes today’sSCARAB_GITHUB_APP_PEM[_FILE]override from “just the PEM” to “any connection + credential.” - Manual / UI — “Add connection” takes
base_url+ a token; the server writes the token through toSecretProviderunder a generated_forge-orgcredential_ref, stores a DB connection row, and never echoes the secret (write-only; shows “•••• set” on edit).
Credential resolution is one path — env-override → SecretProvider — i.e. today’s
connection_credential() generalized. The _forge pseudo-org stays an internal mechanism,
never surfaced to users.
Three details the single-owner rule forces, settled while building it:
- Ownership is persisted, not inferred (
forge_connections.owned_by_config). Only a durable marker lets a later boot tell “the row I provisioned from config” (safe to overwrite) from “a row a human created” (a collision). Config-declared connections are provisioned as real registry rows, so the forge router, the clone-step enricher and webhook resolution need no special case. - A collision refuses the boot. An id declared in config that already exists as a DB-owned connection stops the process before any write, naming both sources — the operator decides which owns it. Un-declaring an entry releases ownership back to the UI rather than deleting the connection: a Project (and its Environments, secrets, RBAC) hangs off its repo bindings, so removal stays an explicit human act.
- Only deployment-supplied credentials fail the boot. A missing/empty
credential.envorcredential.fileis a broken promise by the same deploy that made it ⇒ refuse (ADR-0048). Acredential.secret_refthat is not registered yet is reported DEGRADED instead, because the running server is the only thing that can store it — refusing there would deadlock a fresh database, the same bootstrap trapSCARAB_GITHUB_APP_PEMexists to avoid.
E. Connection health goes one level deeper than “the credential resolves”
Section titled “E. Connection health goes one level deeper than “the credential resolves””(Addendum, 2026-07-26 — git-bug 90644c6.)
GET /v1/connections reports credential_present. That catches a credential that is
gone; it cannot catch a credential that is present and insufficient, which is the
shape both known GitHub App misconfigurations take — and both fail silently:
- An empty webhook-event subscription. GitHub delivers
installation/installation_repositoriesregardless of the subscription list, so the connection registers itself,GET /v1/reposlists Projects, and no push ever starts a run. - A missing
statuses:writegrant. Every status post 403s inside the status pipeline while the Run itself goes green, so the forge simply never shows a check.
So a preflight — GET /v1/connections/{id}/preflight — mints the app’s credential, asks
the forge what it is actually granted and subscribed to, and diffs that against what
Scarab needs. Three decisions carry it:
- The requirement set is data, in one place (
scarab-forge::preflight, keyed byForgeKind), with severity (required= a core promise breaks;recommended= an authored feature does) and, per entry, what silently breaks without it. A diff is only maintainable if the required side is a table rather than strings scattered across the adapter, the API and the UI copy. - The port observes; the domain judges.
ForgePort::describe_capabilitiesreports granted permissions and subscribed events and nothing more; it defaults toUnsupported, so a forge that cannot be introspected reads as unknown rather than as an empty grant set (which the diff would report as “everything is missing”). GitHub answers fromGET /app— the App-level record, which is the configuration an operator would go and change; a fixed-token connection has no App to be misconfigured and says so. - Its own endpoint, and three states. Checking is a live forge round-trip, so it is not
folded into the list (the same reason
supports_resyncis kind-derived rather than probed). It answers 200 withstatus: ok | degraded | unknown— never 501 — because “I could not look” is a health state Settings must render next to the credential line, and must never render as “you are fine”. No credential material is returned: names, levels and event ids only.
Consequences
Section titled “Consequences”- Forgejo becomes actually onboardable from the product for the first time — the [0046] multi-adapter promise is delivered, not just modelled.
- Org and Environment secrets get a real editing home; the [0037] matrix stops being read-only and becomes the single mental model for “what resolves where.”
- New surface:
GET/POST/DELETE /v1/connections,bind_repo/unbind_repo/register_webhookendpoints,GET /v1/connections/{id}/preflight(part E), aconnections:config schema, an editable-matrix component, and a global Settings route.SecretScope::Repois untouched. - The two silent GitHub App misconfigurations stop being a documentation problem: they are reported where the operator already looks, in the language of the setting they must change.
- Single-owner precedence avoids a config-vs-DB dual-write drift hazard (the same reasoning [0037] used to reject a standalone approvals table).
- Deferred: org RBAC / member management (
Principal × scope × Role, [0049]; the/v1/orgs/{org}/bindingsAPI already exists) is the natural third Settings section but is out of scope here — identity, not forge/secrets. Tracked as a follow-up (docs/followups.md).
Alternatives considered
Section titled “Alternatives considered”- Collapse the Org concept (true single-tenant, no Org entity) — reopens [0037]/[0046]/ [0049] and rips out the RBAC/secret inheritance root for no functional gain; the single-Org experience is achieved without it.
- Env secrets in per-Environment editors — co-locates with “configuring prod,” but splits one key-addressed namespace across two tabs and hides the [0037] inheritance view. Rejected for the matrix-as-editor.
- Observe-only connections — small, but re-displays GitHub facts already implicit and leaves Forgejo unusable. Rejected; the value is the onboarding flow.
- Config seeds, UI overrides win — reads as flexible but is exactly the dual-write drift trap: IaC and the running system disagree with no authority. Rejected for single-owner.
- Rename
SecretScope::Repo → Project+project=API param — more internally consistent but broad mechanical churn across crates for a concept users never see, risking collision with therepo-keyed paths. Rejected as feature-time scope creep.