Maitydocs

11. Probe#

GET /projects/{owner}/{slug}/probe?pr=3
GET /projects/{owner}/{slug}/probe?branch=agent-b/credentials
GET /projects/{owner}/{slug}/probe?tag=v0.1.0
GET /projects/{owner}/{slug}/probe?pr=3&max_age=0

Scope project:read. The hub's read-only view of the project's forge (GitHub in V1). The forge API host and the instance token come from the operator's environment only (MAITY_FORGE_GITHUB_TOKEN; the host is fixed per forge kind, api.github.com for GitHub): nothing a project member sets can make the hub send its token anywhere else, and repository.url must be on a configured forge host (§6.1). Which grant reads a project's repository — the namespace owner's GitHub App installation, the owner's own token, or this instance token for a public repository of the operator's own namespaces only — is §11.4; every grant is read-only, and its permissions are the limit of what the project can observe. The hub caches an observation for max_age seconds (default 60, 0 forces a fresh read) and never writes to the forge.

{
  "forge": "github",
  "repository": "oliver-zehentleitner/maity",
  "observed_at": "2026-10-21T13:35:00Z",
  "fresh": true,
  "pr": { "number": 3, "found": true, "url": "…", "title": "hub: skeleton", "state": "open", "draft": false,
          "head_sha": "…", "base": "main", "mergeable": "conflicting",
          "checks": "pending", "checks_detail": [ { "name": "ci / sqlite", "status": "success" } ],
          "merged_at": null, "merge_sha": null },
  "branch": null,
  "tag": null
}

state: open, merged, closed; checks: success, failure, pending, none (no runs — the "conflicting, so no CI" case is mergeable: conflicting with checks: none), unknown. A pull request the forge does not have is {number, found: false} and nothing else; it is an answer, not an error. For branch: {name, head_sha, found}, head_sha null when not found. For tag: {name, sha, found, observed_at}, the same shape as release.tag. Errors: probe_not_configured, probe_unavailable (with the last observation in details.last when one exists). The same reads feed report.observed, release.pull_requests and the forge links on cards (§6.13, read through the forge's issue endpoint for kind: issue), in the background after a report and on the hub's own interval; an observation that changes a card's state emits probe_observed. With the change ticker enabled the probe also reads the merged pull requests of the default branch, their files and, for a contract file, its text at the merge and the base (§17.4); a forge webhook only makes it look sooner.

11.1 Outbound reads#

Every read the hub makes on its own — the probe with the card links it observes and the import (§6.13, §6.14), the logo and banner (§11.2), the Keep the Why export (§11.3) — follows one transport rule. A URL link (kind: url) is never read, so no member-supplied URL becomes a read:

The probe's fixed host satisfies the rule as it is; the rule matters for the reads whose host comes from data.

11.2 Logo and banner#

GET    /projects/{owner}/{slug}/logo        GET    /projects/{owner}/{slug}/banner
PUT    /projects/{owner}/{slug}/logo        PUT    /projects/{owner}/{slug}/banner
DELETE /projects/{owner}/{slug}/logo        DELETE /projects/{owner}/{slug}/banner

GET is scope project:read. The dashboard shows each project with its own image in place of Maity's logo. There are two sources, and the first that has an image wins:

  1. uploaded — an image a human with project:admin uploaded (below);
  2. forge — the repository's image, read server-side through the forge reader;
  3. none — not_found; the dashboard draws the monogram.

The logo and the banner are independent: a project may upload a logo and keep the forge's banner. The hub serves either from its own origin; the browser never loads an image from the forge. The project view says which applies (logo, banner, §6.1).

From the forge.

Forge Square logo Banner
GitHub the owner's avatar, when the owner is an organization: owner.avatar_url of GET /repos/{owner}/{repo} with owner.type Organization (GitHub has no repository avatar; this is what https://github.com/<owner>.png shows). A repository a user owns has no forge logo: a person's photo is not a project's logo, and the dashboard draws the monogram unless one is uploaded the social preview: openGraphImageUrl of the GraphQL repository object, only when usesCustomOpenGraphImage is true (GitHub's generated card is not a banner)
GitLab the project's avatar_url, else the namespace's none
Gitea, Forgejo (Codeberg) the repository's avatar_url, else the owner's none

GitHub is the one forge of V1 (§11); the other rows apply when the operator can configure those forges. Image hosts: for GitHub avatars.githubusercontent.com (avatars) and repository-images.githubusercontent.com (social previews); for the others the forge's own host. An image URL on any other host (a Gravatar fallback, a CDN the hub does not know) is no image. The API reads use the grant §11.4 picks for the repository — the instance token only for a project in the operator's own namespaces; without a grant the hub reads nothing from the forge and has no image (§11.4, no_grant). The GraphQL read is a POST that only queries; the hub still never writes to the forge.

What the hub accepts, whatever the forge said:

The answer is the image bytes with Content-Type from the sniffed type (image/png, image/jpeg, image/webp), X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox, Cross-Origin-Resource-Policy: same-origin, ETag (a digest of the bytes) and Cache-Control: private, max-age=21600. A request with a matching If-None-Match is 304 without a body. The hub keeps an image for 6 hours and then reads it again; a failed refresh keeps the last image, an answer that there is none drops it.

The forge image stays a cached read as written: the hub buffers it, keeps it for 6 hours and then reads it again, never streams it through.

Uploaded. PUT …/logo (…/banner) with the raw image as the body — no JSON, no multipart — and Content-Type: image/png, image/jpeg or image/webp. Human only: a cookie session with X-CSRF-Token (§2.1) and project:admin; an agent credential, whatever its scopes, is forbidden. The same acceptance rules as for the forge apply to the body: the type is sniffed from the magic bytes, and the declared Content-Type must name the sniffed type; SVG and every other type is refused; at most 1 MiB (the body cap of §1.7 does not apply to this call). The hub neither decodes nor re-encodes the image, so it checks no pixel dimensions. It stores the bytes, the sniffed type and the ETag (a digest of the bytes) in its database — a small table keyed by project and kind (logo, banner), the bytes as a binary column portable across the supported engines — and serves them with the headers above. An upload replaces the previous one. The answer is 200 with {"logo": {available, source, etag}} (or banner), the shape of the project view.

DELETE …/logo (…/banner) removes the upload; the forge's image applies again (or none). 200 with the same shape; deleting where nothing was uploaded is 200 as well (§1.5: DELETE is idempotent by itself).

Idempotency-Key as for every other state-changing call (§1.5): optional for the cookie session; a repeat with the same key and the same body replays, a different body is idempotency_mismatch.

Every change of what the project shows — an upload, a delete, a forge refresh that changes the image while the forge is the source — emits project_updated (§8.2) with fields.logo or fields.banner: {available, source, etag}, never the image bytes.

Errors, GET: not_found when there is no image — no upload, and no repository, nothing at the forge or an image the rules above refuse (the dashboard then draws a monogram; that is the client's, not the hub's); probe_unavailable when the forge did not answer and the hub holds no earlier image. PUT: invalid with details.fields.body one of unsupported_type (not PNG, JPEG or WebP by its bytes, or a Content-Type that does not name the sniffed type) and too_large (over 1 MiB); forbidden for an agent or without project:admin; csrf_mismatch.

11.3 The Keep the Why export#

GET /projects/{owner}/{slug}/keep-the-why
GET /projects/{owner}/{slug}/keep-the-why?max_age=0
GET /projects/{owner}/{slug}/keep-the-why?member=hub

Scope project:read. The project's published Keep the Why dashboard state (state.json), read-only: the hub reads what the project already publishes and never reads or writes context/ (context/scope.md: Keep the Why stays external). It holds the export in memory only, never in its database, and keeps no copy of the entries beyond the cache below. One exception, and only this one: the stored public view of a project whose repository the hub last found public (§16.9, Why) holds the summary counts, when the hub read them (observed_at), each family member's name, path and dashboard link, and the attention entries' family member, titles, statuses, topics and links, never anything else of an entry.

Where the URL comes from — the repository only. The dashboard-state: line inside the keep-the-why:config block of the repository's root .keep-the-why on the default branch, read through the forge's contents API (GET /repos/{owner}/{repo}/contents/.keep-the-why on GitHub) with the grant §11.4 picks for the repository (the instance token only for a project in the operator's own namespaces), at most 64 KiB; without a grant nothing is read (§11.4, no_grant). There is no project setting that names or overrides the URL: the repository is the single source of truth, as in Keep the Why itself. No repository, no root .keep-the-why, or no dashboard-state: line in it: ktw_not_configured. A state URL must be https, at most 2048 characters, name a host (no address literal), carry no user info and no port other than 443, else that member's ktw_unavailable with reason state_url_invalid.

The family. A .keep-the-why may carry a keep-the-why:children block, one child per line:

<!-- keep-the-why:children -->
- hub: hub — the server: API, persistence, events, …
- cli: cli — the agent-side command `maity` …
<!-- /keep-the-why:children -->

Each line is - <name>: <location> — <scope>. The hub reads the whole family from the root down and treats every value as untrusted data (the skill's own rule for path-form family locations):

A member is {path, id, name, state_url, dashboard_url, observed_at, summary, error}: path is its directory inside the repository ("" for the root); id is the id: line of its config block (null when absent; cut at 200 characters); name is the <name> its parent's block gives it (null for the root). Members are listed depth-first in the order of the blocks, the root first.

The read. The transport rule of §11.1 for every read, each state.json at most 1 MiB, Accept: application/json; at most 4 reads of one project's family in flight at once. The .keep-the-why files and the exports are cached together for max_age seconds (default 600). A max_age below 30 is taken as 30: a fresh read of the whole family happens at most once per project every 30 seconds, and a closer request answers from the cache with fresh: false. A failed read keeps the member's last good answer until the hub restarts.

What the hub relies on, per export. Unknown keys are ignored, at every level; bodies, authors, project (the hub takes the family from the .keep-the-why files, not from the export), each entry's git, refs, see and the rest are never read.

Key Used as Missing or wrong
state-json the export's version: an integer, or a string whose part before the first . is one; the hub knows major 2 that member's ktw_unavailable, reason state_json_version, details.version
generated summary.generated when it is an RFC 3339 time null
entries an array of objects that member's ktw_unavailable, reason shape
entries[].id a string; the entry's identity the entry is skipped and counted in skipped
entries[].uuid a string; preferred for the link the link uses id
entries[].title a string id
entries[].type a string or an array of strings []
entries[].status active, open, needs-review, pending-confirmation, superseded counted as other
entries[].file, entries[].line the topic file, and the order within it null
topics[].file, topics[].title the topic title of an entry's file topic: null
findings.errors, findings.warnings the linter's counts, integers ≥ 0 null

Untrusted strings. Everything from a .keep-the-why and from an export is data from outside the hub: control characters are removed, id is cut at 300 characters, uuid at 64, title, file, topic and a member's name at 200, each type at 40 and the list at 8, a cut string ends in …. The hub answers JSON only and the dashboard renders these strings as text (web/context/rendering.md), never as HTML. The hub takes no URL from an export: a member's dashboard_url is its state URL without the last path segment, query and fragment, and an entry's link is that member's dashboard_url + #entry/ + the percent-encoded uuid (else id) — the published dashboard's own entry anchor.

{
  "observed_at": "2026-10-22T11:40:00Z",
  "fresh": true,
  "summary": { "members": 4, "failed": 0, "entries": 57,
               "status": { "active": 55, "open": 2, "needs-review": 0, "pending-confirmation": 0,
                           "superseded": 0, "other": 0 },
               "attention": 2, "findings": { "errors": 0, "warnings": 0 } },
  "members": [
    { "path": "", "id": "oliver-zehentleitner---maity", "name": null,
      "state_url": "https://maity.dev/keep-the-why-dashboard/state.json",
      "dashboard_url": "https://maity.dev/keep-the-why-dashboard/",
      "observed_at": "2026-10-22T11:40:00Z",
      "summary": { "generated": "2026-10-22T11:33:15Z", "state_json": 2, "entries": 20,
                   "status": { "active": 19, "open": 1, "needs-review": 0, "pending-confirmation": 0,
                               "superseded": 0, "other": 0 },
                   "attention": 1, "findings": { "errors": 0, "warnings": 0 } },
      "error": null },
    { "path": "hub", "id": "oliver-zehentleitner---maity---hub", "name": "hub",
      "state_url": "https://maity.dev/keep-the-why-dashboard/hub/state.json",
      "dashboard_url": "https://maity.dev/keep-the-why-dashboard/hub/",
      "observed_at": "2026-10-22T11:40:01Z", "summary": { "…": "…" }, "error": null }
  ],
  "attention": [
    { "member": "", "id": "hub.md#…", "uuid": "…", "title": "…", "type": ["question"],
      "status": "open", "file": "hub.md", "topic": "Hub",
      "link": "https://maity.dev/keep-the-why-dashboard/#entry/…" }
  ],
  "family_truncated": false,
  "truncated": false,
  "skipped": 0,
  "error": null
}

members and summary (per member and the aggregate) are the objects the project view carries (§6.1). A member's summary.entries counts the entries read, summary.attention those with status open, needs-review or pending-confirmation; the aggregate adds up the members whose summary is not null (a failed member's last good read included), findings counts are summed where a member has them, members counts the members listed and failed those with an error. attention lists the entries that need attention with the member (path) they belong to — needs-review first, then pending-confirmation, then open, each by member order, file and line — at most 200 in all, then truncated: true. skipped counts skipped entries over all members.

?member=<path> narrows the answer to one member: members holds that one, summary and attention are its own (?member= with an empty value is the root). A path that is not a member of the family is not_found.

Errors: ktw_not_configured; ktw_unavailable when the root .keep-the-why could not be read (details.reason forge_unavailable), with details.last — the last good answer in the shape above — when there is one. A member's own failure is never an error of the call: it is that member's error, {code: "ktw_unavailable", reason} with reason one of forge_unavailable (its .keep-the-why read failed), config_missing (no .keep-the-why at the path), no_state_url (no dashboard-state: line), location_not_followed, state_url_invalid, address_refused, redirect_refused, unavailable (the host did not answer: connection or TLS refused), timeout, http_status (with status), too_large, not_json, state_json_version (with version), shape.

11.4 Forge access per owner#

The maintainer's decision of 2026-10-28: a project's forge reads use a grant from the repository's owner, not only the operator's one token. One instance token cannot read the private repositories of the people who host projects on an instance, and a token from the operator's own account can read the operator's private repositories, which no other project should see through the probe. So the hub reads a project's repository with the first of these that covers it (covers is defined below):

  1. The namespace owner's GitHub App installation (below). The project's namespace owner (§6.25) has linked an installation of the instance's GitHub App, and the installation includes the repository.
  2. The namespace owner's own token (below).
  3. The instance token (MAITY_FORGE_GITHUB_TOKEN, §11), only for a project in one of the operator's own namespaces (below), and only for a repository the forge reports as public. The hub checks private on the repository with the instance token before the first read and on every refresh of the repository (at most every 10 minutes); a repository that is private, or whose visibility the hub cannot read, is never read with the instance token.

Only the namespace owner's grants count: a grant of another member, another owner of the project included, is never used for this project. An organisation (§6.26) holds no grant of its own: for a project of an organisation, "the namespace owner's grants" in this section are those of the organisation owner who confirmed the repository (below), and nobody else's.

The owner confirms the repository. A grant of the owner (sources 1 and 2) is used for a project only while the project's repository.url is one the namespace owner confirmed for it: repository carries owner_confirmed (boolean). Only POST /projects/{owner}/{slug}/repository/confirm confirms: the namespace owner, as a human acting directly (no proxy, no agent), with the step-up of §2.8.4, with {"url": "<the repository URL the owner saw>"}; it confirms only when that is the URL the project carries at that moment, otherwise it answers 409 conflict (details.reason: repository_changed, details.url the current one) and owner_confirmed stays false, so a co-admin's change between what the owner saw and the click is never confirmed with it. Every change of the URL — a PATCH by anyone, the namespace owner included; no other call sets the URL (the card import of §6.14 reads a forge issue and never changes the project's repository) — sets it to false, and until the owner confirms the repository is read with the instance token alone, so only in the operator's own namespaces and only while it is public. So no co-admin can point a project at another of the owner's private repositories and have it read with the owner's access. A transfer (§6.25) resets owner_confirmed to false: the new namespace owner's grants apply only once they confirm the repository, and the transfer's consent request (§6.25) names the repository URL, so the receiver sees what they would be asked to grant. The old owner's grants stop with the transfer.

repository also carries confirmed_by: {id, handle} of the human whose grants the confirmation put to use, or null while owner_confirmed is false. For a project of a human it is the namespace owner. For a project of an organisation any owner of the organisation confirms, under the same rules, with their own grants: the hub records them as confirmed_by and uses their grants alone. A later confirmation by another owner replaces them. When the human named in confirmed_by stops being an owner of the organisation — demoted, removed, leaving, their account deleted — owner_confirmed is reset to false and confirmed_by to null, as on a transfer, and their grants stop at once; another owner confirms again.

When a source covers a repository. The hub decides it per source, on the first read and again on every refresh of the repository (at most every 10 minutes, and at once after a refusal):

The operator's own namespaces. The maintainer's decision of 2026-11-02: projects connect their repository to the forge themselves, through the App or the owner's token; the instance token is the operator's and serves the operator's own projects only. The operator names them in the environment:

Setting Meaning
MAITY_FORGE_INSTANCE_NAMESPACES comma separated namespace names (§6.25: a human's handle or an organisation's name, the {owner} of a project's address); spaces around a name are ignored. Unset or empty: the instance token serves no project

A project is in one of them when its current namespace is one of the names, compared in lower case. A rename of a listed namespace (§6.24) takes its projects out until the operator lists the new name; a name given up stays its id's alias and is never another's (§6.24), so a listed name cannot pass to someone else. A rename and a transfer (§6.25) into or out of a listed namespace apply to the next read at once: the project's current namespace is part of what the choice of its source depends on, like the URL and the owner's grants, not kept for the 10 minutes above.

A listed name that is neither the handle nor an alias of any human or organisation is held for the operator while it is listed: no human or organisation can take it through the hub's API — registration (§2.7.4), POST /orgs and every rename (§6.24) answer invalid, details.fields.handle: "reserved", as for a reserved first segment — so a typing error or an organisation not yet created cannot later be registered by someone else and read with the instance token. maity-hub owner create is not held: it runs on the server with the hub's own configuration and database access, so whoever runs it is the operator, who also sets the list; no request from outside reaches it. An alias needs no hold: it is never another's (§6.24), and its own id may take it back by a rename. The hub logs a warning at start for each held name, and for a listed name that is only an alias, whose namespace is not served until its current handle is listed. An agent profile's handle is not an address and is not checked against the list. Removing a name from the list frees it again.

For every other project the instance token is used for no read at all: not the probe, not the visibility of §16.9, not the first look at a new project's repository, not an import (§6.14). Such a project is read only through its namespace owner's App installation or token, after the owner confirmed the repository; its visibility (§16.9) comes from that read, and the bare number of merges (the one exception below) may come from it as before. Without such a grant probe_via is null, every read answers probe_not_configured with details.reason: no_grant (with a grant the owner has not confirmed, unconfirmed), and the hub counts the repository as one it cannot read: a new project's page starts off (§16.9), and an existing page shows no forge data. The dashboard tells the namespace owner to connect the App or a token, not that the instance reads it.

A repository the forge answers with a redirect (renamed or moved) is not followed: reads stop with details.reason: repository_moved until a project admin sets the new URL, which then needs the owner's confirmation again.

The project's repository (§6.1) says which source is used: probe_via is app, owner_token, instance or null, and probe_configured is true exactly when probe_via is not null. When no source covers the repository, every read answers probe_not_configured with details.reason: no_grant (nothing covers it), private_without_grant (the project is in one of the operator's own namespaces, only the instance token covers it, and the repository is private), installation_suspended or installation_removed (the owner's installation covered it and no longer does), token_rejected (the owner's token was refused by the forge), unconfirmed (a grant of the owner would cover it, but the owner has not confirmed this repository), repository_moved. The reason is shown to project:admin only; other readers get probe_not_configured with details: {}.

What a private repository's data may reach. What the probe reads is project data: it reaches exactly the readers of the record it lands on (a card's forge link, a report's observed, a release, a ticker item, §17.3). A page without sign-in — the badge (§6.20), the public pages (§16.9) and the landing page (§16.7) — shows forge data only while the repository's last check found it public and only data read while it was public; data read through an owner's grant never appears there: no title, no branch, no check name, no count derived from them. One exception: the bare number of merged pull requests (§16.9) may come from a read through the GitHub App or an owner's token, as long as that read found the repository public; nothing else from such a read appears there, no title, no name, no link.

The GitHub App. The operator registers one GitHub App for the instance and configures it in the environment:

Setting Meaning
MAITY_FORGE_GITHUB_APP_ID the App's id
MAITY_FORGE_GITHUB_APP_SLUG the App's URL name, for the installation link
MAITY_FORGE_GITHUB_APP_PRIVATE_KEY the App's private key (PEM), from a Secret, never logged, never in an answer
MAITY_FORGE_GITHUB_APP_CLIENT_ID, MAITY_FORGE_GITHUB_APP_CLIENT_SECRET the App's OAuth client, used once per link to confirm who installed it (below); the secret from a Secret

All five or none: a partial set stops the hub at startup. The App asks for read access only — metadata, contents, pull requests, checks, commit statuses, deployments, issues — and for no write permission and no account permission; the hub never writes to the forge (§11). The App's own webhook is not used in v1; the project webhook (§17.4) stays as it is.

Linking an installation. A human links an installation to their account, not to a project; it then serves every project they own whose repository it includes.

Installation tokens are minted per installation with the App's key when a read needs one, held in the hub's memory until 5 minutes before they expire (the forge issues them for an hour), and never stored, logged or put into an answer. A mint that the forge refuses because the installation was suspended or removed sets the link's status and the reason above; the hub retries a suspended one at most every 10 minutes and a removed one never, until it is linked again.

The owner's own token. A human may store a token of their own as a second source, for a forge setup where an App cannot be installed.

Both sources are the human's own: their links and token are readable by them alone, never by an instance admin, an agent or a runner. A change writes an account record (§18.1) forge_access_changed {forge, source: "app" | "owner_token", change: "linked" | "unlinked" | "suspended" | "removed" | "token_set" | "token_removed" | "token_rejected", login} of that human (login is the forge account the installation or token is on, which the item's title names; never a token), which is a security item for linked and token_set and no notification otherwise.

Rate limits. A source's reads count against that source's limit at the forge (an installation, the owner's token, the instance token); the hub keeps one budget per source and, when the forge says the budget is spent, answers probe_unavailable for that source's projects until the forge's reset time, with details.retry_at. Since one source serves all of an owner's projects, a forced fresh read (max_age=0, §11) is made at most once per 30 seconds per project and target; within that time the cached observation is answered with fresh: false.

Follow-up: organisations. Maity's organisations (§6.26) and the forge's are not linked here. An installation of the App on a forge organisation stays refused (organisation_installation, above); a project of a Maity organisation uses the grants of the owner who confirmed it (above). Whether an installation on a forge organisation may serve a Maity organisation's projects is open for its own contract; until then nothing else in this section changes.