Range-diff !474

back to !474 wiki: architecture and security pages

1:  c2d679e ! 1:  3b23193 docs: architecture and security package
    @@ Metadata
     Author: Christian Cleberg <hello@cleberg.net>
     
      ## Commit message ##
    -    docs: architecture and security package
    +    wiki: architecture and security pages
     
    -    docs/architecture/: system context, components, deployment, trust
    -    boundaries and data flows, identity and access, data and cryptography,
    -    CI and supply chain, operations, a controls matrix and known gaps, with
    -    seven SVG diagrams generated by diagrams/diagrams.py. Claims cite
    -    path:line at 2c08460. The Threat-Model wiki page links to it.
    +    .gitbay/wiki/Architecture/: overview, system context, components,
    +    deployment, trust boundaries and data flows, identity and access, data
    +    and cryptography, CI and supply chain, operations, a controls matrix
    +    and known gaps, with seven SVG diagrams generated by
    +    diagrams/diagrams.py. Claims cite the implementing file and function.
    +    Home and Threat-Model link to it.
     
    -    Ref #255, #256, #257, #258, #259, #260, #261, #262
    +    Ref #255, #256, #257, #258, #259, #260, #261, #262, #272
     
    - ## .gitbay/wiki/Threat-Model.org ##
    + ## .gitbay/wiki/Architecture/00-Overview.org (new) ##
     @@
    - 
    - What the forge trusts, what it refuses to do, and where the boundaries
    - are. This is the reference for security review; it complements the audit
    --log and hardening notes in [[Admin]].
    -+log and hardening notes in [[Admin]]. Diagrams, data flows, a controls
    -+matrix and the open gaps are in the [[https://gitbay.org/krz/gitbay/blob/main/docs/architecture/README.org][architecture and security package]].
    - 
    - * What gitbay never does
    - 
    ++#+title: Architecture and security
    ++
    ++Architecture, trust boundaries and security controls of gitbay, written
    ++for a security reviewer or auditor. Every statement about behaviour
    ++names the file, and usually the function, that implements it;
    ++statements that rest on documentation or deployment files say so. The
    ++pages track the default branch and change in the same merge request as
    ++the code they describe.
    ++
    ++* Scope
    ++
    ++- The =gitbayd= daemon, the =gitbay= CLI and the =gitbay-runner= CI
    ++  runner, all in this repository.
    ++- The reference deployment described by =deploy/= (a single Linux
    ++  host, systemd, rootless podman for CI).
    ++- The iOS client (krz/gitbay-ios) only where it touches the server: the
    ++  JSON API and push notifications.
    ++
    ++Out of scope: the host operating system beyond the unit files and
    ++bootstrap in =deploy/=, the object store holding offsite backups, and
    ++Apple's push service.
    ++
    ++* Figures as of the last review
    ++
    ++| Item             | Value                                              |
    ++|------------------+----------------------------------------------------|
    ++| Reviewed at      | =2c08460= (2026-09-27)                             |
    ++| Schema version   | migration 0059                                     |
    ++| Control commands | 232 registered, 79 marked =ReadOnly=               |
    ++| Go               | 1.27, =CGO_ENABLED=0=                              |
    ++| Direct Go deps   | 15 (=go.mod=)                                      |
    ++
    ++The command count comes from =gitbay help --json= on the live instance;
    ++the =ReadOnly= count from the =ReadOnly: true= literals in
    ++=internal/control/=.
    ++
    ++* Documents
    ++
    ++| # | Document                                           | Diagram                                         |
    ++|---+----------------------------------------------------+-------------------------------------------------|
    ++| 1 | [[file:01-System-Context.org][System context]]                    | [[file:diagrams/01-context.svg][01-context.svg]]                    |
    ++| 2 | [[file:02-Components.org][Components]]                        | [[file:diagrams/02-components.svg][02-components.svg]]                 |
    ++| 3 | [[file:03-Deployment.org][Deployment and network]]            | [[file:diagrams/03-deployment.svg][03-deployment.svg]]                 |
    ++| 4 | [[file:04-Trust-Boundaries.org][Trust boundaries and data flows]]   | [[file:diagrams/04-trust-boundaries.svg][04-trust-boundaries.svg]], [[file:diagrams/06-push-flow.svg][06-push-flow.svg]] |
    ++| 5 | [[file:05-Identity-and-Access.org][Identity and access]]               | [[file:diagrams/05-authorization.svg][05-authorization.svg]]              |
    ++| 6 | [[file:06-Data-and-Cryptography.org][Data and cryptography]]             |                                                 |
    ++| 7 | [[file:07-CI-and-Supply-Chain.org][CI and supply chain]]               | [[file:diagrams/07-ci-flow.svg][07-ci-flow.svg]]                    |
    ++| 8 | [[file:08-Operations.org][Operations]]                        |                                                 |
    ++| 9 | [[file:09-Controls.org][Controls matrix]]                   |                                                 |
    ++| 10 | [[file:10-Known-Gaps.org][Known gaps]]                        |                                                 |
    ++
    ++Reading order for a first pass: 1, 4, 5, 9, 10. The others are
    ++reference.
    ++
    ++* Conventions
    ++
    ++- Paths are relative to the repository root. For a pinned snapshot,
    ++  read these pages at a tag: the wiki is part of the repository.
    ++- "Documented" means the statement rests on the wiki
    ++  (=.gitbay/wiki/=) or =deploy/= rather than on code.
    ++- Numbers such as =#255= are issues on krz/gitbay; =krz/gitbay-ios#15=
    ++  names the other repository.
    ++- Diagrams are SVG with a light and a dark rendering chosen by the
    ++  viewer's colour scheme. They are generated by
    ++  =.gitbay/wiki/Architecture/diagrams/diagrams.py=; edit that and rerun
    ++  it rather than the SVGs.
    ++
    ++* Checking a claim
    ++
    ++The instance answers the same questions the documents make claims
    ++about:
    ++
    ++#+begin_src sh
    ++gitbay help --json                 # the command registry: paths, flags, ReadOnly
    ++curl -s https://gitbay.org/healthz # the deployed commit
    ++curl -sI https://gitbay.org/       # security headers
    ++ssh git@gitbay.org whoami          # identity resolution over stock OpenSSH
    ++#+end_src
    ++
    ++* Related wiki pages
    ++
    ++- [[file:../Threat-Model.org][Threat-Model]] — the project's own threat model; this package extends it.
    ++- [[file:../Parity.org][Parity]] — which capability is reachable from which surface.
    ++- [[file:../Admin.org][Admin]] — configuration, backups, operations.
    ++- [[file:../API.org][API]] — the JSON API.
     
    - ## docs/architecture/01-system-context.org (new) ##
    + ## .gitbay/wiki/Architecture/01-System-Context.org (new) ##
     @@
     +#+title: 1. System context
     +
    @@ docs/architecture/01-system-context.org (new)
     +(=internal/control/control.go=). Stock OpenSSH reaches all of them; the
     +CLI, the web UI and the JSON API are clients of the same registry and
     +do not reimplement logic (=internal/httpd/control.go=,
    -+=internal/httpd/api.go:30=).
    ++=internal/httpd/api.go=).
     +
     +* Actors
     +
    @@ docs/architecture/01-system-context.org (new)
     +
     +| System                    | Direction | Purpose                                   | Code                               |
     +|---------------------------+-----------+-------------------------------------------+------------------------------------|
    -+| ACME CA (Let's Encrypt)   | out       | TLS certificates                          | =cmd/gitbayd/main.go:257-297=       |
    ++| ACME CA (Let's Encrypt)   | out       | TLS certificates                          | =cmd/gitbayd/main.go=       |
     +| SMTP relay                | out       | verification, login links, notifications  | =internal/mail/mail.go=            |
     +| Apple Push Notification   | out       | iOS notifications                         | =internal/push/apns.go=            |
     +| Webhook endpoints         | out       | event delivery, user-configured           | =internal/webhook/webhook.go=      |
     +| Mirror remotes            | out / in  | push and pull mirrors, user-configured    | =internal/mirror/mirror.go=        |
    -+| Package registries        | out       | dependency update checks (opt-in per repo)| =internal/deps/registry.go:18-22=  |
    ++| Package registries        | out       | dependency update checks (opt-in per repo)| =internal/deps/registry.go=  |
     +| Offsite object storage    | out       | restic backups (host timer, not gitbayd)  | documented: Admin wiki             |
     +
     +gitbayd makes no other outbound connection: no telemetry or update
    @@ docs/architecture/01-system-context.org (new)
     +
     +| Setting                          | Default   | Effect                                                      |
     +|----------------------------------+-----------+-------------------------------------------------------------|
    -+| =web.mode=                       | view_only | =accounts= adds login, settings and every web write route (=routes.go:115-232=) |
    ++| =web.mode=                       | view_only | =accounts= adds login, settings and every web write route (=routes.go=) |
     +| =api.enabled=                    | false     | when false there is no credential-bearing HTTP surface       |
     +| =registration.mode=              | closed    | =open= admits unknown SSH keys to =register=; =invite= needs a code |
     +| =git_daemon.enabled=             | false     | anonymous =git://= on 9418                                  |
    @@ docs/architecture/01-system-context.org (new)
     +=registration.mode = open=, all three observable from outside (=/login=,
     +=/register=, =/api/v1/read= answering 401).
     
    - ## docs/architecture/02-components.org (new) ##
    + ## .gitbay/wiki/Architecture/02-Components.org (new) ##
     @@
     +#+title: 2. Components
     +
    @@ docs/architecture/02-components.org (new)
     +
     +=gitbayd= subcommands: =serve=, =check-config=, =migrate=, =admin=,
     +=authorized-keys= and =shell= (for =ssh.mode = system=), =version=, and
    -+the hidden =hook= used by git (=cmd/gitbayd/main.go:76-86=,
    -+=cmd/gitbayd/hook.go:133=).
    ++the hidden =hook= used by git (=cmd/gitbayd/main.go=,
    ++=cmd/gitbayd/hook.go=).
     +
     +* Packages
     +
    @@ docs/architecture/02-components.org (new)
     +
     +* The command registry
     +
    -+Every capability is a =Command= (=internal/control/control.go:79-91=):
    ++Every capability is a =Command= (=internal/control/control.go=):
     +
     +| Field        | Meaning                                                             |
     +|--------------+---------------------------------------------------------------------|
    @@ docs/architecture/02-components.org (new)
     +| =Run=        | the handler                                                         |
     +
     +Every surface builds a =Ctx= and calls =Dispatch=
    -+(=internal/control/control.go:119=):
    ++(=internal/control/control.go=):
     +
     +| Surface      | =Ctx.Source=      | =Ctx.Scope=            | =Ctx.ReadOnly=      | Code                              |
     +|--------------+-------------------+------------------------+---------------------+-----------------------------------|
    -+| SSH          | key fingerprint   | the key's scope        | false               | =internal/sshd/sshd.go:342= (=Exec=) |
    ++| SSH          | key fingerprint   | the key's scope        | false               | =internal/sshd/sshd.go= (=Exec=) |
     +| Web          | =web=             | =full=                 | false               | =internal/httpd/control.go=        |
    -+| JSON API     | =api=             | =full=                 | token scope = read  | =internal/httpd/api.go:30=, =apiread.go:25= |
    ++| JSON API     | =api=             | =full=                 | token scope = read  | =internal/httpd/api.go=, =apiread.go= |
     +| Host (root)  | =host=            | =full=                 | false               | =cmd/gitbayd= admin subcommands    |
     +
     +=Dispatch= applies, in order: =--term= and =--json= stripping; the scope
     +gate; the read-only gate; the disabled-account gate; the =admin= noun
     +gate; the pending-account gate; the per-account write budget; stdin
     +gating; the handler; and an audit row for every successful mutating
    -+command. Details in [[file:05-identity-and-access.org][5. Identity and access]].
    ++command. Details in [[file:05-Identity-and-Access.org][5. Identity and access]].
     +
     +* Background workers
     +
    -+Started by =gitbayd serve= (=cmd/gitbayd/main.go:161-207=):
    ++Started by =gitbayd serve= (=cmd/gitbayd/main.go=):
     +
     +| Worker                | Starts when                 | Trigger                         | Queue / table         |
     +|-----------------------+-----------------------------+---------------------------------+-----------------------|
    @@ docs/architecture/02-components.org (new)
     +* Git hooks
     +
     +Repositories carry generated hook scripts (mode 0755, regenerated at
    -+startup, =internal/hookd/hookd.go:436-451=) that run
    ++startup, =internal/hookd/hookd.go=) that run
     +=gitbayd hook pre-receive|post-receive=. The hook process connects to
    -+the daemon's Unix socket (=<root>/hook.sock=, =hookd.go:70-79=) and asks
    ++the daemon's Unix socket (=<root>/hook.sock=, =hookd.go=) and asks
     +for a decision; the daemon holds the policy. See
    -+[[file:04-trust-boundaries.org][4. Trust boundaries]], flow B.
    ++[[file:04-Trust-Boundaries.org][4. Trust boundaries]], flow B.
     
    - ## docs/architecture/03-deployment.org (new) ##
    + ## .gitbay/wiki/Architecture/03-Deployment.org (new) ##
     @@
     +#+title: 3. Deployment and network
     +
    @@ docs/architecture/03-deployment.org (new)
     +
     +| Port / path          | Protocol          | Owner      | Default     | Auth                                   | Code                                  |
     +|----------------------+-------------------+------------+-------------+----------------------------------------+---------------------------------------|
    -+| 22/tcp               | SSH               | gitbayd    | on          | public key; unknown keys only reach =register= when registration is open | =cmd/gitbayd/main.go:213-222=, =internal/sshd/sshd.go:121= |
    -+| 443/tcp              | HTTPS             | gitbayd    | on          | none for pages; session cookie; bearer token for the API | =cmd/gitbayd/main.go:236-300=        |
    -+| 80/tcp               | HTTP              | gitbayd    | on with ACME| none; ACME HTTP-01 and redirect only   | =cmd/gitbayd/main.go:279-297=         |
    ++| 22/tcp               | SSH               | gitbayd    | on          | public key; unknown keys only reach =register= when registration is open | =cmd/gitbayd/main.go=, =internal/sshd/sshd.go= |
    ++| 443/tcp              | HTTPS             | gitbayd    | on          | none for pages; session cookie; bearer token for the API | =cmd/gitbayd/main.go=        |
    ++| 80/tcp               | HTTP              | gitbayd    | on with ACME| none; ACME HTTP-01 and redirect only   | =cmd/gitbayd/main.go=         |
     +| 9418/tcp             | git://            | gitbayd    | off         | none; public repositories only         | =internal/gitd=                       |
    -+| 2222/tcp             | SSH (operator)    | host sshd  | on          | public key, no passwords, fail2ban     | =deploy/cloud-init.yaml:29-40=        |
    -+| =<root>/hook.sock=   | Unix socket       | gitbayd    | on          | filesystem permissions only            | =internal/hookd/hookd.go:90=          |
    ++| 2222/tcp             | SSH (operator)    | host sshd  | on          | public key, no passwords, fail2ban     | =deploy/cloud-init.yaml=        |
    ++| =<root>/hook.sock=   | Unix socket       | gitbayd    | on          | filesystem permissions only            | =internal/hookd/hookd.go=          |
     +
     +With =ssh.mode = system= the host's sshd serves port 22 instead and
     +invokes =gitbayd authorized-keys= and =gitbayd shell=
    -+(=cmd/gitbayd/main.go:224-226=).
    ++(=cmd/gitbayd/main.go=).
     +
     +HTTP server limits: =ReadHeaderTimeout= 10 s, =IdleTimeout= 2 min,
     +=MaxHeaderBytes= 64 KiB, no =WriteTimeout= so long git transfers and
    -+live build logs can stream (=cmd/gitbayd/main.go:230-234=).
    ++live build logs can stream (=cmd/gitbayd/main.go=).
     +
     +There is no metrics endpoint. =/healthz= reports the deployed commit and
     +a database check.
    @@ docs/architecture/03-deployment.org (new)
     +
     +| Unit                   | User        | Hardening (from the unit files)                                                                   |
     +|------------------------+-------------+---------------------------------------------------------------------------------------------------|
    -+| =gitbayd.service=      | =gitbay=    | =CAP_NET_BIND_SERVICE= only; =NoNewPrivileges=; =ProtectSystem=strict= with write access to =/var/lib/gitbay= and =/var/backups/gitbay= only; =ProtectHome=; =PrivateTmp=; =PrivateDevices=; kernel, clock and cgroup protections; =RestrictNamespaces=; =MemoryDenyWriteExecute=; =RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX=; =SystemCallFilter=@system-service= (=deploy/cloud-init.yaml:205-241=) |
    ++| =gitbayd.service=      | =gitbay=    | =CAP_NET_BIND_SERVICE= only; =NoNewPrivileges=; =ProtectSystem=strict= with write access to =/var/lib/gitbay= and =/var/backups/gitbay= only; =ProtectHome=; =PrivateTmp=; =PrivateDevices=; kernel, clock and cgroup protections; =RestrictNamespaces=; =MemoryDenyWriteExecute=; =RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX=; =SystemCallFilter=@system-service= (=deploy/cloud-init.yaml=) |
     +| =gitbay-runner.service=| =ci-runner= | =MemoryMax=6G=, =CPUQuota=300%=, =Delegate=yes=, =KillMode=mixed=, =RestrictSUIDSGID=yes=. =NoNewPrivileges=, =ProtectKernelTunables= and =ProtectControlGroups= are relaxed because rootless podman needs =newuidmap=, a proc mount and a writable delegated cgroup; the reasons are in =deploy/gitbay-runner.override.conf= |
    -+| CI containers          | subordinate uids of =ci-runner= | rootless podman, =--pull=never=, operator-provisioned image; see [[file:07-ci-and-supply-chain.org][7. CI]] |
    ++| CI containers          | subordinate uids of =ci-runner= | rootless podman, =--pull=never=, operator-provisioned image; see [[file:07-CI-and-Supply-Chain.org][7. CI]] |
     +| backup, db-backup, gc, monitor timers | =gitbay= | nightly full archive, hourly database snapshot, weekly =git gc=, hourly health heartbeat (=deploy/cloud-init.yaml=) |
     +
     +* Filesystem
    @@ docs/architecture/03-deployment.org (new)
     +| Path                              | Contents                                   | Mode set by code / deploy |
     +|-----------------------------------+--------------------------------------------+---------------------------|
     +| =/var/lib/gitbay= (=server.root=) | everything below                           | 0750 (cloud-init)         |
    -+| =<root>/gitbay.db=                | SQLite database                            | 0640 (=internal/store/store.go:59-64=) |
    ++| =<root>/gitbay.db=                | SQLite database                            | 0640 (=internal/store/store.go=) |
     +| =<root>/repos/<owner>/<name>.git= | bare repositories                          | process umask             |
    -+| =<root>/lfs=                      | LFS objects, content-addressed             | 0755 directories (=internal/lfs/lfs.go:61=) |
    -+| =<root>/ssh/host_ed25519=         | SSH host key                               | 0600 in a 0700 directory (=internal/sshd/sshd.go:103-114=) |
    ++| =<root>/lfs=                      | LFS objects, content-addressed             | 0755 directories (=internal/lfs/lfs.go=) |
    ++| =<root>/ssh/host_ed25519=         | SSH host key                               | 0600 in a 0700 directory (=internal/sshd/sshd.go=) |
     +| =<root>/acme=                     | ACME account key and certificates          | autocert defaults         |
     +| =<root>/hooks=                    | generated hook scripts                     | 0755                      |
     +| =/etc/gitbay/config.toml=         | configuration, including SMTP password     | 0640 (cloud-init)         |
    @@ docs/architecture/03-deployment.org (new)
     +
     +| Destination            | Trigger                       | TLS                                          | Guard                                                          |
     +|------------------------+-------------------------------+----------------------------------------------+----------------------------------------------------------------|
    -+| ACME directory         | certificate issue and renewal | yes                                          | host policy limits names to the site and claimed pages domains (=main.go:257-273=) |
    -+| SMTP relay             | queued mail                   | STARTTLS when offered                        | Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go:38-47=) |
    ++| ACME directory         | certificate issue and renewal | yes                                          | host policy limits names to the site and claimed pages domains (=main.go=) |
    ++| SMTP relay             | queued mail                   | STARTTLS when offered                        | Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) |
     +| APNs                   | queued push                   | yes, HTTP/2                                  | provider token signed with the operator's .p8 key              |
    -+| Webhook URLs           | recorded events               | yes when https; certificate verified         | private, loopback and link-local targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go:26-93=) |
    -+| Mirror URLs            | mirror schedule               | per URL                                      | the same address check at save time (=internal/control/mirrorcmd.go:58=); git makes the connection, so there is no connect-time re-check |
    -+| Package registries     | dependency checks             | yes                                          | fixed hosts; only the package name varies (=internal/deps/registry.go:15-22=) |
    ++| Webhook URLs           | recorded events               | yes when https; certificate verified         | private, loopback and link-local targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go=) |
    ++| Mirror URLs            | mirror schedule               | per URL                                      | the same address check at save time (=internal/control/mirrorcmd.go=); git makes the connection, so there is no connect-time re-check |
    ++| Package registries     | dependency checks             | yes                                          | fixed hosts; only the package name varies (=internal/deps/registry.go=) |
     +
     +* Host firewall
     +
     +=deploy/cloud-init.yaml= opens 22, 80, 443 and 2222 inbound with ufw.
     +Outbound traffic is not restricted, including from CI containers. See
    -+[[file:10-known-gaps.org][10. Known gaps]].
    ++[[file:10-Known-Gaps.org][10. Known gaps]].
     +
     +* Change path
     +
    @@ docs/architecture/03-deployment.org (new)
     +   (=deploy/install.sh=).
     +3. =/healthz= reports the commit now serving.
     
    - ## docs/architecture/04-trust-boundaries.org (new) ##
    + ## .gitbay/wiki/Architecture/04-Trust-Boundaries.org (new) ##
     @@
     +#+title: 4. Trust boundaries and data flows
     +
    @@ docs/architecture/04-trust-boundaries.org (new)
     +
     +| ID  | Boundary                           | What crosses                                     | Control at the boundary                                                                 |
     +|-----+------------------------------------+--------------------------------------------------+-----------------------------------------------------------------------------------------|
    -+| TB1 | Z0 → Z1 SSH                        | key auth, exec requests, git packs               | public-key auth, per-IP failure limit (=internal/sshd/sshd.go:121=, =ratelimit.go=); unknown keys reach only =register= |
    -+| TB2 | Z0 → Z1 HTTPS                      | page requests, form posts, API calls, fetches, LFS | TLS; session cookie or bearer token; =checkOrigin= on posts; CSP and security headers (=internal/httpd/routes.go:290=); smart HTTP is fetch-only (=smart.go=) |
    -+| TB3 | identity → data                    | every command                                    | =Dispatch= gates, then =resolveRepo= with =policy= predicates; unreadable repositories are indistinguishable from missing ones ([[file:05-identity-and-access.org][5]]) |
    ++| TB1 | Z0 → Z1 SSH                        | key auth, exec requests, git packs               | public-key auth, per-IP failure limit (=internal/sshd/sshd.go=, =ratelimit.go=); unknown keys reach only =register= |
    ++| TB2 | Z0 → Z1 HTTPS                      | page requests, form posts, API calls, fetches, LFS | TLS; session cookie or bearer token; =checkOrigin= on posts; CSP and security headers (=internal/httpd/routes.go=); smart HTTP is fetch-only (=smart.go=) |
    ++| TB3 | identity → data                    | every command                                    | =Dispatch= gates, then =resolveRepo= with =policy= predicates; unreadable repositories are indistinguishable from missing ones ([[file:05-Identity-and-Access.org][5]]) |
     +| TB4 | Z1 → Z3 git                        | argv, repository path, stdin packs               | argv built by code, never a shell; repository path from the database, not the request (=internal/gitutil=) |
    -+| TB5 | Z3 → Z1 hook socket                | ref updates, repository id, user id, key scope, commit objects | the daemon decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go:130=). The socket trusts the ids in the request, so access to the socket is equivalent to acting as any user; it is reachable only through the =gitbay= user's filesystem |
    -+| TB6 | Z4 ↔ Z1 runner channel             | build claims (with secrets for trusted builds), logs, results | runner-scoped SSH key; claims limited to attached repositories; secrets only when the build is trusted (=internal/control/build.go:548=) |
    ++| TB5 | Z3 → Z1 hook socket                | ref updates, repository id, user id, key scope, commit objects | the daemon decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go=). The socket trusts the ids in the request, so access to the socket is equivalent to acting as any user; it is reachable only through the =gitbay= user's filesystem |
    ++| TB6 | Z4 ↔ Z1 runner channel             | build claims (with secrets for trusted builds), logs, results | runner-scoped SSH key; claims limited to attached repositories; secrets only when the build is trusted (=internal/control/build.go=) |
     +| TB7 | Z5 → Z4 container                  | build steps, workspace, build home               | rootless podman, operator-provisioned image, cgroup limits; the build home is shared per repository and the network is open (#255, #260) |
    -+| TB8 | Z1 → Z0 outbound                   | webhooks, mirrors, mail, push                    | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-deployment.org][3]]) |
    -+| TB9 | user content → browser             | Markdown and Org bodies, READMEs, filenames      | HTML sanitised (=ugcHTML=, =internal/httpd/web.go:1322=, bluemonday); CSP =script-src 'none'= |
    ++| TB8 | Z1 → Z0 outbound                   | webhooks, mirrors, mail, push                    | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-Deployment.org][3]]) |
    ++| TB9 | user content → browser             | Markdown and Org bodies, READMEs, filenames      | HTML sanitised (=ugcHTML=, =internal/httpd/web.go=, bluemonday); CSP =script-src 'none'= |
     +| TB10| Z6 → everything                    | host shell                                       | operator SSH on 2222, keys only, fail2ban; append-only offsite backup credentials |
     +
     +* Flows
    @@ docs/architecture/04-trust-boundaries.org (new)
     +
     +1. Client opens SSH; =authenticate= looks up the key fingerprint and
     +   records user id, key id and scope in the connection
    -+   (=internal/sshd/sshd.go:121-158=). TB1.
    ++   (=internal/sshd/sshd.go=). TB1.
     +2. Each exec request: =runExec= reloads the account, touches the key's
    -+   last-used time, calls =Exec= (=sshd.go:299-312=).
    ++   last-used time, calls =Exec= (=sshd.go=).
     +3. =Exec= tokenizes the command line (no shell) and routes git
     +   transport verbs to =runGit=, everything else to =control.Dispatch=
    -+   with =Source= set to the key fingerprint (=sshd.go:342-363=).
    ++   with =Source= set to the key fingerprint (=sshd.go=).
     +4. =Dispatch= gates and runs the handler; mutating successes are
    -+   audited (=internal/control/control.go:119-191=). TB3.
    ++   audited (=internal/control/control.go=). TB3.
     +
     +** B. git push over SSH
     +
    @@ docs/architecture/04-trust-boundaries.org (new)
     +
     +1. =runGit= resolves the repository, applies the deploy-key or account
     +   checks, archive and pull-mirror refusals and the owner's storage
    -+   quota (=sshd.go:388=). TB3.
    ++   quota (=sshd.go=). TB3.
     +2. =git receive-pack= runs with the hook socket path, repository id,
    -+   user id and key scope in its environment (=sshd.go:438-462=). TB4.
    ++   user id and key scope in its environment (=sshd.go=). TB4.
     +3. git runs =pre-receive=, which is =gitbayd hook pre-receive=. It
     +   reads the ref updates, computes ancestry in git's quarantine
     +   environment and asks the daemon over the socket
    -+   (=cmd/gitbayd/hook.go:133-189=). TB5.
    ++   (=cmd/gitbayd/hook.go=). TB5.
     +4. The daemon applies =policy.CheckPush= (protected branches,
     +   =require-mr=, protected tags, server-owned =refs/merge-requests/*=)
     +   and, when the repository requires signed commits, asks for every
     +   incoming commit object and verifies each
    -+   (=internal/hookd/hookd.go:130-190=).
    ++   (=internal/hookd/hookd.go=).
     +5. On refusal the hook exits 1 and git rejects the push atomically.
     +6. On success git runs =post-receive=; the daemon records events,
     +   marks mirrors dirty, queues CI, syncs merge request heads, processes
     +   =Closes #N= references and audits force pushes
    -+   (=hookd.go:218=).
    ++   (=hookd.go=).
     +
     +The server's own merge of a merge request does not pass through the
     +hooks: =runMRMerge= updates the ref with a compare-and-swap
    -+(=internal/control/mr.go:1462=) after =MergeGates=
    -+(=mr.go:1568=). When signed commits are required only fast-forward
    ++(=internal/control/mr.go=) after =MergeGates=
    ++(=mr.go=). When signed commits are required only fast-forward
     +merges are allowed, so the server never writes an unsigned commit
    -+(=mr.go:1273=).
    ++(=mr.go=).
     +
     +** C. Fetch over smart HTTP
     +
     +=GET info/refs= and =POST git-upload-pack= serve public repositories
     +only; a private repository answers 404. =git-receive-pack= over HTTP
     +always answers a pkt-line refusal, so there is no password prompt and
    -+no HTTP write path (=internal/httpd/smart.go:88-141=,
    -+=routes.go:28-38=). TB2.
    ++no HTTP write path (=internal/httpd/smart.go=,
    ++=routes.go=). TB2.
     +
     +** D. Web read and write
     +
    -+1. The session cookie is hashed and looked up (=accounts.go:36-46=).
    ++1. The session cookie is hashed and looked up (=accounts.go=).
     +2. Pages dispatch read commands into the registry with =Source=web= and
     +   decode the JSON result into the template (=internal/httpd/control.go=).
    -+3. Form posts pass =checkOrigin= (=accounts.go:67=), dispatch the
    ++3. Form posts pass =checkOrigin= (=accounts.go=), dispatch the
     +   matching command, and map the exit code to a redirect or an error on
     +   the page. Three toggles (pin, watch, mark read) write the store
     +   directly instead (#261).
    @@ docs/architecture/04-trust-boundaries.org (new)
     +** E. JSON API
     +
     +1. =apiAuth= hashes the bearer token and looks it up; any failure is a
    -+   uniform 401 (=internal/httpd/api.go:127=).
    ++   uniform 401 (=internal/httpd/api.go=).
     +2. Per-account rate limit, with writes at a tenth of the read budget.
     +3. =POST /api/v1/cmd= dispatches any command except git transport;
     +   =GET /api/v1/read= refuses anything not marked =ReadOnly=, so a GET
    -+   cannot write (=apiread.go:25-44=).
    ++   cannot write (=apiread.go=).
     +4. Exit codes map to HTTP status: 0→200, 2→400, 3→404, 4→403, else 500.
     +
     +** F. LFS
     +
     +=git-lfs-authenticate= over SSH applies the same repository checks as
     +git transport and returns a one-hour HMAC token scoped to repository
    -+and operation (=internal/sshd/lfs.go=, =internal/lfs/lfs.go:129=). The
    ++and operation (=internal/sshd/lfs.go=, =internal/lfs/lfs.go=). The
     +HTTP batch, upload and download endpoints verify that token; public
     +repositories allow anonymous download. Objects are verified against
     +their SHA-256 id on upload.
    @@ docs/architecture/04-trust-boundaries.org (new)
     +
     +1. =/login= takes a username or email; per-IP and per-account limits
     +   (5 per hour) apply, and every non-eligible case returns the same
    -+   response (=internal/control/loginlink.go:38-110=).
    ++   response (=internal/control/loginlink.go=).
     +2. A 32-byte token is mailed; only its hash is stored, valid 15 minutes.
     +3. =/login?token== consumes it atomically, rechecks the account and
    -+   sets the session cookie (=accounts.go:123-152=).
    ++   sets the session cookie (=accounts.go=).
     +
     +=ssh git@host web login= mints the same kind of link, valid 5 minutes,
    -+to an already-authenticated key (=internal/control/web.go:80-99=).
    ++to an already-authenticated key (=internal/control/web.go=).
     +
     +** H. CI build
     +
    -+See [[file:07-ci-and-supply-chain.org][7. CI and supply chain]].
    ++See [[file:07-CI-and-Supply-Chain.org][7. CI and supply chain]].
     
    - ## docs/architecture/05-identity-and-access.org (new) ##
    + ## .gitbay/wiki/Architecture/05-Identity-and-Access.org (new) ##
     @@
     +#+title: 5. Identity and access
     +
    @@ docs/architecture/05-identity-and-access.org (new)
     +
     +| Property     | Values / behaviour                                                                  | Code |
     +|--------------+-------------------------------------------------------------------------------------+------|
    -+| Registration | =closed= (host bootstrap only), =invite= (single-use code bound to an email), =open= (email required) | =internal/control/register.go:280-316= |
    -+| Pending      | open registrations start pending until email is verified; a pending account may run only =email verify=, =email add=, =whoami=, =help=, and has no git access | =internal/control/control.go:172-175,250-253=, =internal/sshd/sshd.go= |
    -+| Disabled     | refused in =Dispatch=, in SSH exec and at login-link redemption                     | =control.go:164-166= |
    -+| Admin        | =users.is_admin=; set only by =admin user create --admin= or promotion by an admin; gates the whole =admin= noun | =control.go:169-171= |
    ++| Registration | =closed= (host bootstrap only), =invite= (single-use code bound to an email), =open= (email required) | =internal/control/register.go= |
    ++| Pending      | open registrations start pending until email is verified; a pending account may run only =email verify=, =email add=, =whoami=, =help=, and has no git access | =internal/control/control.go=, =internal/sshd/sshd.go= |
    ++| Disabled     | refused in =Dispatch=, in SSH exec and at login-link redemption                     | =control.go= |
    ++| Admin        | =users.is_admin=; set only by =admin user create --admin= or promotion by an admin; gates the whole =admin= noun | =control.go= |
     +
     +* Credentials
     +
    @@ docs/architecture/05-identity-and-access.org (new)
     +| Invite             | random code                                     | SHA-256 hash, single use         | one registration for one email             | as issued                     | consumed on use                     |
     +| LFS transfer token | HMAC-SHA256 over repo, operation, expiry        | not stored (stateless)           | one repository, upload or download         | 1 h                           | expiry only                         |
     +
    -+Generation and hashing: =internal/store/sessions.go:14-26= (=NewToken=,
    ++Generation and hashing: =internal/store/sessions.go= (=NewToken=,
     +=HashToken=, =crypto/rand=). Cookie attributes: =HttpOnly=,
     +=SameSite=Lax=, =Secure= unless TLS is off, =MaxAge= 7 days
    -+(=internal/httpd/accounts.go:159-168=). Token scope values are
    ++(=internal/httpd/accounts.go=). Token scope values are
     +constrained by a database =CHECK= as well as the command
     +(=internal/store/migrations/0004_api_tokens.up.sql=).
     +
    @@ docs/architecture/05-identity-and-access.org (new)
     +| =full= token   | all the account may run         | not over the API                  |
     +| =read= token   | commands marked =ReadOnly=      | not over the API                  |
     +
    -+Sources: =internal/control/control.go:156-161=,
    -+=internal/policy/access.go:46-79=.
    ++Sources: =internal/control/control.go=,
    ++=internal/policy/access.go=.
     +
     +* Authorization decision
     +
     +Two layers decide every request.
     +
    -+1. *=Dispatch=* (=internal/control/control.go:119-191=), in order:
    ++1. *=Dispatch=* (=internal/control/control.go=), in order:
     +   scope gate, read-only gate, disabled account, =admin= noun, pending
     +   account, per-account write budget, stdin gating, handler, audit.
     +2. *The handler*, which resolves the repository with =resolveRepo=
    -+   (=internal/control/repo.go:228-249=) and a predicate from
    ++   (=internal/control/repo.go=) and a predicate from
     +   =internal/policy/access.go=:
     +
     +| Predicate  | True when                                                                |
    @@ docs/architecture/05-identity-and-access.org (new)
     +checks =CanRead=. If the caller cannot read the repository the answer is
     +"not found", identical to a missing repository. Only a caller who can
     +read it gets "permission denied" for a write. git transport follows the
    -+same rule (=internal/sshd/sshd.go:388=), and so does smart HTTP, which
    ++same rule (=internal/sshd/sshd.go=), and so does smart HTTP, which
     +serves public repositories only.
     +
     +Deploy keys bypass the grant model entirely: =runGit= checks only that
     +the key's scope names this repository and allows the operation
    -+(=policy.DeployScopeAllows=, =internal/policy/access.go:60-76=).
    ++(=policy.DeployScopeAllows=, =internal/policy/access.go=).
     +
     +* Repository write protections
     +
     +Enforced in the pre-receive hook, so they apply to every credential
    -+that reaches git transport (=internal/policy/access.go:93-139=,
    -+=internal/hookd/hookd.go:130-190=):
    ++that reaches git transport (=internal/policy/access.go=,
    ++=internal/hookd/hookd.go=):
     +
     +| Setting (=repo settings …=) | Effect                                                                  |
     +|---------------------------+-------------------------------------------------------------------------|
    @@ docs/architecture/05-identity-and-access.org (new)
     +| (always)                  | =refs/merge-requests/*= is server-owned                                 |
     +
     +Merge gates, evaluated by =MergeGates= for =mr merge=, the web merge
    -+button and the displayed status (=internal/control/mr.go:1568-1717=):
    ++button and the displayed status (=internal/control/mr.go=):
     +
     +| Gate                  | Satisfied when                                                              |
     +|-----------------------+-----------------------------------------------------------------------------|
    @@ docs/architecture/05-identity-and-access.org (new)
     +
     +- CSRF: =SameSite=Lax= withholds the cookie on cross-site posts;
     +  =checkOrigin= rejects a post whose =Origin= host differs from the
    -+  request host (=internal/httpd/accounts.go:67-79=).
    ++  request host (=internal/httpd/accounts.go=).
     +- Headers on every response: CSP =default-src 'self'; script-src
     +  'none'; style-src 'self' 'unsafe-inline'; img-src * data:;
     +  object-src 'none'; base-uri 'none'; form-action 'self';
     +  frame-ancestors 'none'=, =X-Frame-Options: DENY=, =nosniff=,
     +  =Referrer-Policy: no-referrer=, =Cross-Origin-Opener-Policy:
     +  same-origin=, and HSTS for one year with subdomains when TLS is on
    -+  (=internal/httpd/routes.go:290-305=).
    ++  (=internal/httpd/routes.go=).
     +- Destructive web actions (key, email and PGP removal, release, snippet,
     +  team and label deletion, user disable and demote) require the target's
     +  name typed into the form (=internal/httpd/confirm.go=).
    @@ docs/architecture/05-identity-and-access.org (new)
     +|-------------------------+-------------------+-----------------------+---------------------------------------|
     +| SSH auth failures       | 10 per minute     | client IP             | =internal/sshd/ratelimit.go=          |
     +| API requests            | 120 per minute    | account, or IP if anonymous | =internal/httpd/apilimit.go=    |
    -+| API writes              | a tenth of the above | same               | =apilimit.go:43-45=                   |
    -+| Command writes, all surfaces | =limits.write_rate= (60 per minute) | account | =internal/control/control.go:231-248= |
    -+| Login links (web form)  | 5 per hour, plus per-IP | account and IP  | =internal/control/loginlink.go:19,78-84= |
    -+| Email verification mails| 5 per hour        | account               | =internal/control/register.go:173=    |
    ++| API writes              | a tenth of the above | same               | =apilimit.go=                   |
    ++| Command writes, all surfaces | =limits.write_rate= (60 per minute) | account | =internal/control/control.go= |
    ++| Login links (web form)  | 5 per hour, plus per-IP | account and IP  | =internal/control/loginlink.go= |
    ++| Email verification mails| 5 per hour        | account               | =internal/control/register.go=    |
     +
     +=X-Forwarded-For= is honoured only from addresses listed in
    -+=http.trusted_proxies= (=internal/httpd/apilimit.go:106-138=).
    ++=http.trusted_proxies= (=internal/httpd/apilimit.go=).
     
    - ## docs/architecture/06-data-and-cryptography.org (new) ##
    + ## .gitbay/wiki/Architecture/06-Data-and-Cryptography.org (new) ##
     @@
     +#+title: 6. Data and cryptography
     +
    @@ docs/architecture/06-data-and-cryptography.org (new)
     +
     +No table stores client IP addresses as a column. The daemon writes a
     +client IP into an audit row only for authentication failures and
    -+throttling (=internal/sshd/sshd.go:130-151=).
    ++throttling (=internal/sshd/sshd.go=).
     +
     +* At rest
     +
     +| Item                                  | Protection                                                     |
     +|---------------------------------------+----------------------------------------------------------------|
    -+| API tokens, sessions, login links, email codes, invites | SHA-256 of a 256-bit random value; the value is shown once and never stored (=internal/store/sessions.go:14-26=) |
    ++| API tokens, sessions, login links, email codes, invites | SHA-256 of a 256-bit random value; the value is shown once and never stored (=internal/store/sessions.go=) |
     +| CI secrets, webhook secrets, mirror tokens, APNs device tokens | stored in clear in SQLite; protection is filesystem permissions and the rule that values are write-only through the interface |
     +| SQLite file                           | mode 0640, directory 0750                                      |
     +| Backups                               | the local archive is not encrypted; restic encrypts the offsite copy |
    @@ docs/architecture/06-data-and-cryptography.org (new)
     +| Runner ↔ server          | SSH                                                           |
     +| SMTP                     | STARTTLS when the relay offers it                            |
     +| APNs                     | TLS, HTTP/2                                                  |
    -+| Webhooks                 | TLS when the URL is https; HMAC-SHA256 body signature in =X-Gitbay-Signature-256= (=internal/webhook/webhook.go:145-149=) |
    -+| Mirrors                  | per URL; token passed through =GIT_ASKPASS=, never argv (=internal/mirror/mirror.go:84-97=) |
    ++| Webhooks                 | TLS when the URL is https; HMAC-SHA256 body signature in =X-Gitbay-Signature-256= (=internal/webhook/webhook.go=) |
    ++| Mirrors                  | per URL; token passed through =GIT_ASKPASS=, never argv (=internal/mirror/mirror.go=) |
     +
     +The TLS configuration uses Go's defaults; no minimum version or cipher
     +list is set in code.
    @@ docs/architecture/06-data-and-cryptography.org (new)
     +
     +| Use                             | Primitive                                  | Code                               |
     +|---------------------------------+--------------------------------------------+------------------------------------|
    -+| Token generation                | =crypto/rand=, 32 bytes                    | =internal/store/sessions.go:14=    |
    -+| Token storage                   | SHA-256                                    | =sessions.go:23=                   |
    -+| LFS transfer tokens             | HMAC-SHA256, secret in =settings=          | =internal/lfs/lfs.go:120-175=      |
    -+| Webhook signatures              | HMAC-SHA256                                | =internal/webhook/webhook.go:145=  |
    ++| Token generation                | =crypto/rand=, 32 bytes                    | =internal/store/sessions.go=    |
    ++| Token storage                   | SHA-256                                    | =sessions.go=                   |
    ++| LFS transfer tokens             | HMAC-SHA256, secret in =settings=          | =internal/lfs/lfs.go=      |
    ++| Webhook signatures              | HMAC-SHA256                                | =internal/webhook/webhook.go=  |
     +| APNs provider token             | ES256 JWT (ECDSA P-256)                    | =internal/push/token.go=           |
    -+| SSH host key                    | ed25519                                    | =internal/sshd/sshd.go:75-114=     |
    ++| SSH host key                    | ed25519                                    | =internal/sshd/sshd.go=     |
     +| Commit and tag signatures       | verify OpenPGP (ProtonMail go-crypto) and SSHSIG | =internal/sig=               |
    -+| LFS object ids                  | SHA-256                                    | =internal/lfs/lfs.go:71=           |
    ++| LFS object ids                  | SHA-256                                    | =internal/lfs/lfs.go=           |
     +
     +Signature verification results are cached in =commit_signatures= with
     +the global =key_epoch= at the time of verification. Any change to a
     +trust input (a key added or removed, an email verified) bumps the
     +epoch, which invalidates every cached result (=internal/store/users.go=,
    -+=internal/control/sig.go:124=).
    ++=internal/control/sig.go=).
     +
     +The server holds no signing key and signs nothing. A "verified" badge
     +means a user's own key signed the commit.
    @@ docs/architecture/06-data-and-cryptography.org (new)
     +- Secrets enter only on stdin. A command must set =ReadsStdin=
     +  to receive stdin at all; =TestStdinCommandsReadStdin= enforces it.
     +  Examples: =repo secret set=, =repo deploy-key add=, =repo import
    -+  --token-stdin= (=internal/control/build.go:65-72=, =import.go:88-90=).
    ++  --token-stdin= (=internal/control/build.go=, =import.go=).
     +- Secrets are listed by name, never echoed back.
     +- The audit log stores argv with flag values stripped
    -+  (=internal/control/control.go:199-221=).
    ++  (=internal/control/control.go=).
     +- Mail errors are logged with addresses redacted
    -+  (=internal/notify/notify.go:76-80=).
    ++  (=internal/notify/notify.go=).
     +- CI secrets travel in the runner's claim only for trusted builds and
     +  reach the container as environment variables through a 0600 env file
     +  or podman's =--env NAME= pass-through, never argv
    -+  (=cmd/gitbay-runner/isolate.go:113-129=).
    ++  (=cmd/gitbay-runner/isolate.go=).
     +
     +* Retention
     +
     +Configured under =[retention]= for =audit=, =events=,
     +=webhook_deliveries=, =mail= and =push=; unset means keep forever.
     +Expired sessions and tokens are swept hourly regardless
    -+(=internal/config/config.go:121-146=, =cmd/gitbayd/main.go:529=).
    ++(=internal/config/config.go=, =cmd/gitbayd/main.go=).
     +Accounts that never verify are removed after
     +=registration.pending_expiry=. =account export= gives a user their data.
     
    - ## docs/architecture/07-ci-and-supply-chain.org (new) ##
    + ## .gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org (new) ##
     @@
     +#+title: 7. CI and supply chain
     +
    @@ docs/architecture/07-ci-and-supply-chain.org (new)
     +| steps per job                | 50, each at most 4096 bytes                                   |
     +| path filters                 | 50 each for =paths= and =paths-ignore=                        |
     +| job name                     | =^[a-z0-9][a-z0-9_-]{0,39}$=                                  |
    -+| image                        | a restricted reference; it becomes a podman argument, so no whitespace or shell characters (=ci.go:33-39=) |
    ++| image                        | a restricted reference; it becomes a podman argument, so no whitespace or shell characters (=ci.go=) |
     +| triggers                     | push, merge request, =schedule= (cron), =tags= (glob)         |
     +
     +A file that does not parse sets a =ci/config= failure status on the
    @@ docs/architecture/07-ci-and-supply-chain.org (new)
     +* Build lifecycle
     +
     +1. *Queue.* The post-receive hook calls =queueJobs=
    -+   (=internal/control/build.go:773=). Each job gets a =ci/<job>= status:
    ++   (=internal/control/build.go=). Each job gets a =ci/<job>= status:
     +   =pending= when queued, =skipped= when path filters exclude it, or
     +   =success= copied from an earlier build of the same tree (#177).
     +   Merge requests from forks are queued against the target repository
     +   with =trusted = false=.
     +2. *Claim.* A runner calls =runner next= over SSH
    -+   (=build.go:463=). Allowed for a =runner=-scoped key or an admin; a
    ++   (=build.go=). Allowed for a =runner=-scoped key or an admin; a
     +   runner key claims only for repositories it is attached to with
     +   =repo runner add=. Untrusted builds are claimable only by a runner
    -+   started with =-untrusted= (=internal/store/builds.go:85-90=). The
    ++   started with =-untrusted= (=internal/store/builds.go=). The
     +   claim returns id, repository, job, commit, ref, steps, image and —
    -+   for trusted builds only — the repository's secrets (=build.go:548=).
    ++   for trusted builds only — the repository's secrets (=build.go=).
     +3. *Run.* The runner clones over SSH into =build-<id>=, starts a
     +   container and runs each step with =podman exec … sh -c <step>=
     +   (=cmd/gitbay-runner/isolate.go=).
     +4. *Log.* =runner log <id>= streams stdin into the build row; the server
    -+   ends the stream if the build is cancelled (=build.go:570=).
    ++   ends the stream if the build is cancelled (=build.go=).
     +5. *Result.* =runner done <id> success|failure= sets the status,
     +   records an event and mails the repository's watchers a log tail on
    -+   failure (=build.go:648=).
    ++   failure (=build.go=).
     +6. *Reap.* The scheduler fails a running build whose log stream closed
     +   more than 2 minutes ago, or that started more than 90 minutes ago
    -+   (=internal/store/builds.go:127-197=).
    ++   (=internal/store/builds.go=).
     +
     +Who may do what:
     +
    @@ docs/architecture/07-ci-and-supply-chain.org (new)
     +
     +| Control                    | Implementation                                                             |
     +|----------------------------+----------------------------------------------------------------------------|
    -+| Isolation mode             | =podman= by default; =none= must be chosen explicitly and logs a warning; an unknown value or missing prerequisites refuse start (=isolate.go:44-88=) |
    ++| Isolation mode             | =podman= by default; =none= must be chosen explicitly and logs a warning; an unknown value or missing prerequisites refuse start (=isolate.go=) |
     +| Container runtime          | rootless podman under the =ci-runner= user and its subordinate uid range   |
     +| Image                      | =--pull=never=; images are built by the operator (=deploy/Containerfile.ci=) and referenced by tag |
    -+| Workspace                  | =<workdir>/build-<id>=, removed after the build; workdir must be 0700 and owned by the runner (=main.go:549-580=) |
    ++| Workspace                  | =<workdir>/build-<id>=, removed after the build; workdir must be 0700 and owned by the runner (=main.go=) |
     +| Build home                 | =<workdir>/home/<owner>/<name>=, one per repository, mounted read-write, shared by trusted and untrusted builds of that repository (#255) |
     +| Secrets                    | env file 0600 outside the workspace, or =--env NAME= for multi-line values |
     +| Resources                  | per-build cgroup with =memory.max= and =cpu.max= written by the runner; unit-level =MemoryMax=6G=, =CPUQuota=300%= |
    @@ docs/architecture/07-ci-and-supply-chain.org (new)
     +| Deploy         | =make deploy= refuses a dirty tree, then copies, checks config and restarts over operator SSH |
     +| CI image       | built on the host from =deploy/Containerfile.ci= (=golang:1.27-trixie= plus git-lfs, gnupg, openssh, python3, sqlite3); tagged, never pulled at build time |
     
    - ## docs/architecture/08-operations.org (new) ##
    + ## .gitbay/wiki/Architecture/08-Operations.org (new) ##
     @@
     +#+title: 8. Operations
     +
    @@ docs/architecture/08-operations.org (new)
     +
     +| Recorded                                     | How                                                  |
     +|----------------------------------------------+------------------------------------------------------|
    -+| Every successful mutating command, every surface | =Dispatch= writes =cmd <path>= with pruned argv and the source: key fingerprint, =web=, =api= or =host= (=internal/control/control.go:183-189=) |
    ++| Every successful mutating command, every surface | =Dispatch= writes =cmd <path>= with pruned argv and the source: key fingerprint, =web=, =api= or =host= (=internal/control/control.go=) |
     +| SSH authentication failures and throttling   | =auth.failed= (IP, fingerprint), =auth.throttled= (IP) |
     +| Registration                                  | =auth.registered=, =pending.expired=                 |
     +| Administration                                | =admin user.*=, =admin email.*=, =admin invite.issued=, =admin repo.*=, =admin mr.prune=, =admin runners.forget= |
    @@ docs/architecture/08-operations.org (new)
     +
     +- The database snapshot is taken before repositories are read, so a
     +  push during the backup leaves only unreferenced objects
    -+  (=cmd/gitbayd/backup.go:21-26=).
    ++  (=cmd/gitbayd/backup.go=).
     +- Excluded: WAL files, the hook socket, askpass scripts, generated
     +  hooks.
     +- =gitbayd admin backup --verify= checks SQLite integrity and that every
    -+  repository the database names is present (=backup.go:187=). It does
    ++  repository the database names is present (=backup.go=). It does
     +  not check git object connectivity.
     +- The host's restic credentials are append-only; the key that can
     +  delete or prune snapshots is held off the host, so a compromised host
    @@ docs/architecture/08-operations.org (new)
     +Open connections of a removed key keep working until they close; see
     +#256.
     
    - ## docs/architecture/09-controls.org (new) ##
    + ## .gitbay/wiki/Architecture/09-Controls.org (new) ##
     @@
     +#+title: 9. Controls matrix
     +
     +One row per control an auditor typically asks about. *Status*: =in
     +place= (implemented and cited), =partial= (implemented with a stated
    -+limit), =gap= (not implemented; see [[file:10-known-gaps.org][10]]). Categories follow the
    ++limit), =gap= (not implemented; see [[file:10-Known-Gaps.org][10]]). Categories follow the
     +chapter names of OWASP ASVS 4.0 where one fits.
     +
     +** Architecture (V1)
     +
     +| Control                                     | Status   | Evidence                                                         |
     +|---------------------------------------------+----------+------------------------------------------------------------------|
    -+| One authorization path for every surface     | partial  | all surfaces call =control.Dispatch= (=internal/control/control.go:119=); three web toggles write the store directly (#261) |
    ++| One authorization path for every surface     | partial  | all surfaces call =control.Dispatch= (=internal/control/control.go=); three web toggles write the store directly (#261) |
     +| No server-side signing key                  | in place | =internal/sig= verifies only                                     |
    -+| Least functionality by default              | in place | API, web accounts, git://, push and registration default off (=internal/config/config.go:270-290=) |
    ++| Least functionality by default              | in place | API, web accounts, git://, push and registration default off (=internal/config/config.go=) |
     +| No git library; git runs as a subprocess with built argv | in place | =internal/gitutil=                                     |
     +
     +** Authentication (V2) and session management (V3)
    @@ docs/architecture/09-controls.org (new)
     +| No passwords anywhere                       | in place | SSH keys, emailed single-use links, bearer tokens                |
     +| Credentials stored as hashes                | in place | SHA-256 of 256-bit random values (=internal/store/sessions.go=)  |
     +| Brute-force limit on SSH auth               | in place | 10 failures a minute per IP (=internal/sshd/ratelimit.go=)       |
    -+| Account enumeration resistance at login     | in place | uniform response (=internal/control/loginlink.go:33-76=)         |
    -+| Session cookie flags                        | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go:161=) |
    ++| Account enumeration resistance at login     | in place | uniform response (=internal/control/loginlink.go=)         |
    ++| Session cookie flags                        | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) |
     +| Session lifetime                            | partial  | 7 days absolute, no idle timeout                                  |
     +| Credential expiry                           | partial  | API tokens optional; SSH and deploy keys none                    |
     +| Revocation takes effect immediately         | gap      | removed SSH key keeps open connections (#256)                    |
    @@ docs/architecture/09-controls.org (new)
     +
     +| Control                                     | Status   | Evidence                                                         |
     +|---------------------------------------------+----------+------------------------------------------------------------------|
    -+| Deny by default on private data             | in place | =CanRead= requires owner, public or grant (=internal/policy/access.go:14=) |
    -+| Private resources indistinguishable from missing | in place | =resolveRepo= (=internal/control/repo.go:228-249=), =runGit=, smart HTTP |
    -+| Credential scopes narrow account rights     | in place | key and token scopes (=control.go:156-161=, =policy/access.go:46-79=) |
    -+| Server-side write protections              | in place | pre-receive =CheckPush=, signed commits (=internal/hookd/hookd.go:130=) |
    ++| Deny by default on private data             | in place | =CanRead= requires owner, public or grant (=internal/policy/access.go=) |
    ++| Private resources indistinguishable from missing | in place | =resolveRepo= (=internal/control/repo.go=), =runGit=, smart HTTP |
    ++| Credential scopes narrow account rights     | in place | key and token scopes (=control.go=, =policy/access.go=) |
    ++| Server-side write protections              | in place | pre-receive =CheckPush=, signed commits (=internal/hookd/hookd.go=) |
     +| Merge gates                                 | partial  | =MergeGates=; any writer can post a =ci/*= status (#258)         |
     +| Admin functions isolated                    | in place | =admin= noun gated in =Dispatch=; =audit= admin-only             |
    -+| CSRF protection                             | in place | SameSite=Lax plus =checkOrigin= (=accounts.go:67=)               |
    ++| CSRF protection                             | in place | SameSite=Lax plus =checkOrigin= (=accounts.go=)               |
     +| Typed confirmation for destructive web actions | in place | =internal/httpd/confirm.go=                                  |
     +
     +** Input handling and output encoding (V5)
     +
     +| Control                                     | Status   | Evidence                                                         |
     +|---------------------------------------------+----------+------------------------------------------------------------------|
    -+| User markup sanitised                       | in place | =ugcHTML= with bluemonday (=internal/httpd/web.go:1322=)         |
    -+| No script execution in pages                | in place | CSP =script-src 'none'= (=internal/httpd/routes.go:290=)         |
    -+| Control characters stripped at the terminal | in place | =termSafe= (=internal/control/term.go:54=)                    |
    ++| User markup sanitised                       | in place | =ugcHTML= with bluemonday (=internal/httpd/web.go=)         |
    ++| No script execution in pages                | in place | CSP =script-src 'none'= (=internal/httpd/routes.go=)         |
    ++| Control characters stripped at the terminal | in place | =termSafe= (=internal/control/term.go=)                    |
     +| No shell in command execution               | in place | =protocol.Tokenize= for SSH argv; git and podman with argv slices |
     +| Parsers fuzzed                              | partial  | five fuzz targets run briefly by =deploy/audit.sh=               |
     +
    @@ docs/architecture/09-controls.org (new)
     +| Control                                     | Status   | Evidence                                                         |
     +|---------------------------------------------+----------+------------------------------------------------------------------|
     +| TLS for all authenticated HTTP              | in place | ACME or certificate files; HSTS                                  |
    -+| Secrets encrypted at rest                   | gap      | CI secrets, webhook secrets, mirror tokens stored in clear ([[file:06-data-and-cryptography.org][6]]) |
    ++| Secrets encrypted at rest                   | gap      | CI secrets, webhook secrets, mirror tokens stored in clear ([[file:06-Data-and-Cryptography.org][6]]) |
     +| Secrets kept out of argv, logs and output   | in place | =ReadsStdin=, pruned audit argv, write-only secret commands      |
     +| Local backups encrypted                     | gap      | tar.gz in clear; offsite copy encrypted by restic                |
    -+| Data retention configurable                 | in place | =[retention]= (=internal/config/config.go:121-146=)              |
    ++| Data retention configurable                 | in place | =[retention]= (=internal/config/config.go=)              |
     +| User data export                            | in place | =account export=                                                 |
     +
     +** Logging (V7)
     +
     +| Control                                     | Status   | Evidence                                                         |
     +|---------------------------------------------+----------+------------------------------------------------------------------|
    -+| Security-relevant writes audited            | in place | every successful mutating command (=control.go:183-189=)         |
    ++| Security-relevant writes audited            | in place | every successful mutating command (=control.go=)         |
     +| Authentication failures audited             | in place | =auth.failed=, =auth.throttled=                                  |
     +| Denied attempts audited                     | gap      | refused commands are not recorded                                |
     +| Audit log tamper resistance                 | gap      | same database, writable by the daemon user                       |
    @@ docs/architecture/09-controls.org (new)
     +| Control                                     | Status   | Evidence                                                         |
     +|---------------------------------------------+----------+------------------------------------------------------------------|
     +| Untrusted code runs isolated                | partial  | rootless podman, cgroup limits; shared build home per repository (#255) |
    -+| No secrets for untrusted builds             | in place | =internal/control/build.go:548=                                  |
    -+| Runner limited to attached repositories     | in place | =runnerMayBuild= (=build.go:425-462=)                            |
    ++| No secrets for untrusted builds             | in place | =internal/control/build.go=                                  |
    ++| Runner limited to attached repositories     | in place | =runnerMayBuild= (=build.go=)                            |
     +| Build images fixed by the operator          | in place | =--pull=never=                                                   |
     +| Build network egress restricted             | gap      | #260                                                             |
     +| Build results reused only across equal trust | gap     | tree reuse ignores trust and image (#258)                        |
    @@ docs/architecture/09-controls.org (new)
     +
     +| Control                                     | Status   | Evidence                                                         |
     +|---------------------------------------------+----------+------------------------------------------------------------------|
    -+| Rate limits on API and writes               | in place | [[file:05-identity-and-access.org][5. Rate limits]]                         |
    ++| Rate limits on API and writes               | in place | [[file:05-Identity-and-Access.org][5. Rate limits]]                         |
     +| Concurrency limit on git pack generation    | gap      | #262                                                             |
    -+| Service hardening                           | in place | systemd sandboxing ([[file:03-deployment.org][3]])                                     |
    ++| Service hardening                           | in place | systemd sandboxing ([[file:03-Deployment.org][3]])                                     |
     +| Backups offsite and append-only             | in place | restic with append-only credentials (documented)                 |
     +| Restore tested                              | gap      | #259                                                             |
     +| Migrations validated before commit          | gap      | foreign-key check runs after commit (#261)                       |
     +| Signed, reviewed changes to production      | in place | signed commits, =require-mr=, ff-only merges, clean-tree deploys |
     
    - ## docs/architecture/10-known-gaps.org (new) ##
    + ## .gitbay/wiki/Architecture/10-Known-Gaps.org (new) ##
     @@
     +#+title: 10. Known gaps
     +
    -+Open weaknesses as of the baseline commit. Issues on krz/gitbay are
    -+public; this list gives the title and the consequence, not a
    -+reproduction.
    ++Open weaknesses. Issues on krz/gitbay are public; this page gives the
    ++title and the consequence, not a reproduction. The current list is the
    ++open issues labelled =security=:
    ++https://gitbay.org/krz/gitbay/issues?label=security. The table below is
    ++what the 2026-09-27 review found; remove a row when its issue closes.
     +
     +* Filed
     +
    @@ docs/architecture/10-known-gaps.org (new)
     +
     +* Not yet filed
     +
    -+Found while writing this package.
    ++Found during the 2026-09-27 review.
     +
     +| Area             | Gap                                                                                   | Where                                       |
     +|------------------+---------------------------------------------------------------------------------------+---------------------------------------------|
     +| Data at rest     | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite            | =build_secrets=, =webhooks=, =mirrors=      |
    -+| Backups          | The local backup archive is not encrypted                                             | =cmd/gitbayd/backup.go:83-84=               |
    -+| Audit            | Refused commands are not audited; the audit table is writable by the daemon user       | =internal/control/control.go:183-189=       |
    -+| Sessions         | Web sessions have a 7-day absolute lifetime and no idle timeout                        | =internal/httpd/accounts.go:147=            |
    ++| Backups          | The local backup archive is not encrypted                                             | =cmd/gitbayd/backup.go=               |
    ++| Audit            | Refused commands are not audited; the audit table is writable by the daemon user       | =internal/control/control.go=       |
    ++| Sessions         | Web sessions have a 7-day absolute lifetime and no idle timeout                        | =internal/httpd/accounts.go=            |
     +| Credentials      | SSH and deploy keys never expire                                                      | =ssh_keys=                                  |
    -+| Login links      | =web login= over SSH is not counted against the 5-per-hour login-link limit            | =internal/control/web.go:80-99=             |
    -+| SSRF             | Mirror URLs are checked when saved but not when git connects, so a DNS change can redirect a mirror to a private address | =internal/control/mirrorcmd.go:58= |
    -+| Mail             | STARTTLS is used only when the relay offers it                                        | =internal/mail/mail.go:38-42=               |
    ++| Login links      | =web login= over SSH is not counted against the 5-per-hour login-link limit            | =internal/control/web.go=             |
    ++| SSRF             | Mirror URLs are checked when saved but not when git connects, so a DNS change can redirect a mirror to a private address | =internal/control/mirrorcmd.go= |
    ++| Mail             | STARTTLS is used only when the relay offers it                                        | =internal/mail/mail.go=               |
     +| TLS              | Go defaults; no explicit minimum version                                              | =cmd/gitbayd/main.go=                       |
    -+| Hook socket      | Any process that can open =hook.sock= can claim any user id; it relies on the data directory's permissions | =internal/hookd/hookd.go:90-102= |
    ++| Hook socket      | Any process that can open =hook.sock= can claim any user id; it relies on the data directory's permissions | =internal/hookd/hookd.go= |
     +
     +* Questions an auditor will ask that have no answer yet
     +
    @@ docs/architecture/10-known-gaps.org (new)
     +| What can a build reach on the host's network?             | configuration inspected, reachability untested (#260) |
     +| Have the collaboration features been used by independent users? | no; one human user, tests only     |
     
    - ## docs/architecture/README.org (new) ##
    -@@
    -+#+title: gitbay architecture and security package
    -+
    -+Architecture, trust boundaries and security controls of gitbay, written
    -+for a security reviewer or auditor. Every statement about behaviour
    -+cites the code that implements it as =path:line= against the commit
    -+named below; statements that rest on documentation or deployment files
    -+say so.
    -+
    -+* Scope
    -+
    -+- The =gitbayd= daemon, the =gitbay= CLI and the =gitbay-runner= CI
    -+  runner, all in this repository.
    -+- The reference deployment described by =deploy/= (a single Linux
    -+  host, systemd, rootless podman for CI).
    -+- The iOS client (krz/gitbay-ios) only where it touches the server: the
    -+  JSON API and push notifications.
    -+
    -+Out of scope: the host operating system beyond the unit files and
    -+bootstrap in =deploy/=, the object store holding offsite backups, and
    -+Apple's push service.
    -+
    -+* Baseline
    -+
    -+| Item             | Value                                              |
    -+|------------------+----------------------------------------------------|
    -+| Commit           | =2c08460= plus this package                        |
    -+| Schema version   | migration 0059                                     |
    -+| Control commands | 232 registered, 79 marked =ReadOnly=               |
    -+| Go               | 1.27, =CGO_ENABLED=0=                              |
    -+| Direct Go deps   | 15 (=go.mod=)                                      |
    -+
    -+The command count comes from =gitbay help --json= on the live instance;
    -+the =ReadOnly= count from the =ReadOnly: true= literals in
    -+=internal/control/=.
    -+
    -+* Documents
    -+
    -+| # | Document                                           | Diagram                                         |
    -+|---+----------------------------------------------------+-------------------------------------------------|
    -+| 1 | [[file:01-system-context.org][System context]]                    | [[file:diagrams/01-context.svg][01-context.svg]]                    |
    -+| 2 | [[file:02-components.org][Components]]                        | [[file:diagrams/02-components.svg][02-components.svg]]                 |
    -+| 3 | [[file:03-deployment.org][Deployment and network]]            | [[file:diagrams/03-deployment.svg][03-deployment.svg]]                 |
    -+| 4 | [[file:04-trust-boundaries.org][Trust boundaries and data flows]]   | [[file:diagrams/04-trust-boundaries.svg][04-trust-boundaries.svg]], [[file:diagrams/06-push-flow.svg][06-push-flow.svg]] |
    -+| 5 | [[file:05-identity-and-access.org][Identity and access]]               | [[file:diagrams/05-authorization.svg][05-authorization.svg]]              |
    -+| 6 | [[file:06-data-and-cryptography.org][Data and cryptography]]             |                                                 |
    -+| 7 | [[file:07-ci-and-supply-chain.org][CI and supply chain]]               | [[file:diagrams/07-ci-flow.svg][07-ci-flow.svg]]                    |
    -+| 8 | [[file:08-operations.org][Operations]]                        |                                                 |
    -+| 9 | [[file:09-controls.org][Controls matrix]]                   |                                                 |
    -+| 10 | [[file:10-known-gaps.org][Known gaps]]                        |                                                 |
    -+
    -+Reading order for a first pass: 1, 4, 5, 9, 10. The others are
    -+reference.
    -+
    -+* Conventions
    -+
    -+- =path:line= is relative to the repository root at the baseline commit.
    -+  Line numbers drift; the function names given alongside do not.
    -+- "Documented" means the statement rests on the wiki
    -+  (=.gitbay/wiki/=) or =deploy/= rather than on code.
    -+- Numbers such as =#255= are issues on krz/gitbay; =krz/gitbay-ios#15=
    -+  names the other repository.
    -+- Diagrams are SVG with a light and a dark rendering chosen by the
    -+  viewer's colour scheme. They are generated by
    -+  =diagrams/diagrams.py=; edit that and rerun it rather than the SVGs.
    -+
    -+* Checking a claim
    -+
    -+The instance answers the same questions the documents make claims
    -+about:
    -+
    -+#+begin_src sh
    -+gitbay help --json                 # the command registry: paths, flags, ReadOnly
    -+curl -s https://gitbay.org/healthz # the deployed commit
    -+curl -sI https://gitbay.org/       # security headers
    -+ssh git@gitbay.org whoami          # identity resolution over stock OpenSSH
    -+#+end_src
    -+
    -+* Related wiki pages
    -+
    -+- [[file:../../.gitbay/wiki/Threat-Model.org][Threat-Model]] — the project's own threat model; this package extends it.
    -+- [[file:../../.gitbay/wiki/Parity.org][Parity]] — which capability is reachable from which surface.
    -+- [[file:../../.gitbay/wiki/Admin.org][Admin]] — configuration, backups, operations.
    -+- [[file:../../.gitbay/wiki/API.org][API]] — the JSON API.
    -
    - ## docs/architecture/diagrams/01-context.svg (new) ##
    + ## .gitbay/wiki/Architecture/diagrams/01-context.svg (new) ##
     @@
     +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 590" width="970" height="590" role="img" aria-labelledby="t d">
     +<title id="t">1. System context</title><desc id="d">Actors and external systems around a gitbay instance.</desc>
    @@ docs/architecture/diagrams/01-context.svg (new)
     +<text class="ts" x="423.59999999999997" y="570" text-anchor="start">untrusted or refused</text>
     +</svg>
     
    - ## docs/architecture/diagrams/02-components.svg (new) ##
    + ## .gitbay/wiki/Architecture/diagrams/02-components.svg (new) ##
     @@
     +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 680" width="970" height="680" role="img" aria-labelledby="t d">
     +<title id="t">2. Components inside gitbayd</title><desc id="d">Packages of the gitbayd daemon and how requests move between them.</desc>
    @@ docs/architecture/diagrams/02-components.svg (new)
     +<text class="ts" x="179.0" y="660" text-anchor="start">on-disk state</text>
     +</svg>
     
    - ## docs/architecture/diagrams/03-deployment.svg (new) ##
    + ## .gitbay/wiki/Architecture/diagrams/03-deployment.svg (new) ##
     @@
     +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 640" width="970" height="640" role="img" aria-labelledby="t d">
     +<title id="t">3. Deployment (reference host)</title><desc id="d">Processes, users, ports and files on the single gitbay host.</desc>
    @@ docs/architecture/diagrams/03-deployment.svg (new)
     +<text class="ts" x="327.6" y="620" text-anchor="start">untrusted code</text>
     +</svg>
     
    - ## docs/architecture/diagrams/04-trust-boundaries.svg (new) ##
    + ## .gitbay/wiki/Architecture/diagrams/04-trust-boundaries.svg (new) ##
     @@
     +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 650" width="970" height="650" role="img" aria-labelledby="t d">
     +<title id="t">4. Trust boundaries</title><desc id="d">Zones Z0 to Z6 and the boundaries TB1 to TB10 that data crosses between them.</desc>
    @@ docs/architecture/diagrams/04-trust-boundaries.svg (new)
     +<text class="ts" x="395.8" y="640" text-anchor="start">untrusted code</text>
     +</svg>
     
    - ## docs/architecture/diagrams/05-authorization.svg (new) ##
    + ## .gitbay/wiki/Architecture/diagrams/05-authorization.svg (new) ##
     @@
     +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 860" width="970" height="860" role="img" aria-labelledby="t d">
     +<title id="t">5. Authorization decision</title><desc id="d">How a request is authorised: Dispatch gates, then repository resolution with a policy predicate, and the git transport path.</desc>
    @@ docs/architecture/diagrams/05-authorization.svg (new)
     +<text class="ts" x="253.20000000000002" y="835" text-anchor="start">allowed</text>
     +</svg>
     
    - ## docs/architecture/diagrams/06-push-flow.svg (new) ##
    + ## .gitbay/wiki/Architecture/diagrams/06-push-flow.svg (new) ##
     @@
     +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 780" width="970" height="780" role="img" aria-labelledby="t d">
     +<title id="t">6. git push over SSH</title><desc id="d">Sequence of a push: SSH checks, pre-receive decision by the daemon, optional signature verification, post-receive side effects.</desc>
    @@ docs/architecture/diagrams/06-push-flow.svg (new)
     +<text class="ts" x="250.0" y="693" text-anchor="middle">result</text>
     +</svg>
     
    - ## docs/architecture/diagrams/07-ci-flow.svg (new) ##
    + ## .gitbay/wiki/Architecture/diagrams/07-ci-flow.svg (new) ##
     @@
     +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 660" width="970" height="660" role="img" aria-labelledby="t d">
     +<title id="t">7. CI build</title><desc id="d">Sequence of a CI build from push to result, including the claim and where secrets travel.</desc>
    @@ docs/architecture/diagrams/07-ci-flow.svg (new)
     +<text class="ts" x="506" y="588" text-anchor="start">scheduler reaps: log closed &gt; 2 min, or started &gt; 90 min</text>
     +</svg>
     
    - ## docs/architecture/diagrams/diagrams.py (new) ##
    + ## .gitbay/wiki/Architecture/diagrams/diagrams.py (new) ##
     @@
     +#!/usr/bin/env python3
     +"""Emit the architecture package's SVG diagrams.
     +
    -+Usage: python3 docs/architecture/diagrams/diagrams.py docs/architecture/diagrams
    ++Usage: python3 .gitbay/wiki/Architecture/diagrams/diagrams.py .gitbay/wiki/Architecture/diagrams
     +"""
     +import sys
     +from xml.sax.saxutils import escape as esc
    @@ docs/architecture/diagrams/diagrams.py (new)
     +authz()
     +push()
     +ci()
    +
    + ## .gitbay/wiki/Home.org ##
    +@@ .gitbay/wiki/Home.org: CLI-first git forge: SSH is the API, the web is a rendering.
    + - [[Admin][Admin guide]] — install, configuration reference, backup, security
    + - [[API][API and webhooks]] — the JSON API contract, tokens, payloads
    + - [[Threat-Model][Threat model]] — what the forge trusts and never does
    ++- [[file:Architecture/00-Overview.org][Architecture and security]] — diagrams, trust boundaries, controls, known gaps
    + - [[Parity][Parity]] — what each surface can do, and what has no page yet
    + - [[Performance][Performance]] — stress-test numbers from importing git.git
    + 
    +
    + ## .gitbay/wiki/Threat-Model.org ##
    +@@
    + 
    + What the forge trusts, what it refuses to do, and where the boundaries
    + are. This is the reference for security review; it complements the audit
    +-log and hardening notes in [[Admin]].
    ++log and hardening notes in [[Admin]]. Diagrams, data flows, a controls
    ++matrix and the open gaps are in the [[file:Architecture/00-Overview.org][Architecture]] pages.
    + 
    + * What gitbay never does
    +