Skip to content

Harvest authenticates as a new panto-observer GitHub App via a token broker, never a PAT


Harvest authenticates as a new panto-observer GitHub App via a token broker, never a PAT

Section titled “Harvest authenticates as a new panto-observer GitHub App via a token broker, never a PAT”

Date: 2026-08-18 Status: open — step 1 of 3 landed, see Progress Supersedes: none Superseded-by: none — current Upholds: MR-14

01-harvest-derive.json today authenticates N3 and N5 with a githubApi credential — an operator PAT scoped read:org + repo. repo is a READ-WRITE scope. One operator PAT across 100 tenant orgs makes blast radius the union of 100 orgs and includes write, while docs/panto/README.md declares write-surface: none. The declaration and the credential disagree, and the credential wins. That README already gates promotion on exactly this replacement.

The paragraph above was wrong about the deployed credential, twice over, and it is left standing rather than rewritten because the correction is the point.

The credential was read directly on 2026-08-19 (n8n hab.so1.io, GitHub token settings). It was panto-harvest-readonly: fine-grained, expiring 2026-11-16, granting Contents: Read + Metadata: Read on one repository (smo1-io/smo1-io) plus org Members: Read. Not a classic token, no repo scope, no write permission anywhere, and reaching one org rather than the union of all of them.

Two things follow.

The decision below is unaffected. Its case rests on per-tenant isolation, fail-closed discovery, and keeping the App key out of n8n — none of which depend on the credential having been write-capable.

The actual defect was the opposite of the one stated. The grant was narrower than the query: N5 enumerates organization.repositories while the token could see one repo, and the only coverage guard (if (rawRepos.length === 0) throw) passes at one. Runs emitted an org-labelled harvest_hash, topology, services list and governance posture derived from a single repository, with no field recording that coverage was partial. Fixed at panto-cloud@f8f9c1e. Recorded in panto-cloud:docs/n4b-scope-addendum.md, Addendum 2.

Retraction, 2026-08-19 — the paragraph immediately above is also wrong

Section titled “Retraction, 2026-08-19 — the paragraph immediately above is also wrong”

Left standing for the same reason the Context paragraph above it was: the direction of each error is the useful record.

registry.yaml in this repo maps each slug to a single governing repository — the one repo tying an organisation to the control plane. smo1-io resolves to https://github.com/smo1-io/smo1-io, which is precisely the one repository panto-harvest-readonly granted. The grant matched the design. Coverage was not partial, and execution 419 on hab.so1.io (2026-08-18 20:25, success, repos_total: 1, posture: governed) was correct output read as a symptom.

The mismatch runs the other way: N5’s organization.repositories enumeration is broader than the design, not the grant narrower than the query. What the harvest should target instead is a design question ruled separately; this is a retraction of a factual claim only.

One defect the retracted reasoning introduced, recorded as fact: N2e Token check (panto-cloud@f8f9c1e) refuses any installation where repository_selection !== 'all'. Under a governing-repo design the correct installation is selected, so N2e refuses every correct install.

None of this touches the Decision below. A fine-grained PAT is bound to one resource owner and cannot span 31+ organisations however many repositories per organisation are in scope.

This record specifies three steps (see Sequencing, below). Step 1 has landed; steps 2 and 3 have not. Status stays open.

StepState
1. In-n8n JWT-signing bridgeLanded, and verified against real GitHub 2026-08-19. panto-cloud@f8f9c1e, amended by b64bdbf. Nodes N2a–N2e mint an App JWT and exchange it for a per-installation token scoped contents:read + metadata:read. The githubApi credential is gone from N3/N5. See Step 1 verified below.
2. External broker in panto-core/broker/Not started. No code exists in panto-core/broker/; none of the three required deletions is implemented anywhere.
3. Delete the bridgeBlocked on 2, and unscheduled. Nothing reads the condition that would trigger it — no check, no owner, no date. The App private key stays in n8n until someone acts without being prompted to.

Step 1 had landed but had never run against GitHub. It has now, end to end, and the things this record decided by reading are observed:

  • The App exists as specified — panto-observer, id 4649713 — and the live App declares contents:read + metadata:read, no organization permissions and no write permissions. Read back off the App itself, not off what was requested.
  • registry.yaml is read from petrova-codes/petrova through a repo-scoped installation token: 41 entries, 38 with a url.
  • A slug resolves and harvests: smo1-io → smo1-io/smo1-io @ c2309bde2faf, read-only, registry-asserted.
  • The per-repository boundary is enforced by GitHub, not by us. Asked to mint a token scoped to petrova-canary — a real repository in petrova-codes, simply not granted to the installation — GitHub returns 422 There is at least one repository that does not exist or is not accessible to the parent installation. The installation-vs-registry refusal is therefore served by the substrate before any workflow code runs.

This last point is what the Decision’s per-repo scoping rested on, and it was an argument from the docs until now.

Two defects were found and fixed in the course of verifying, both in checks rather than in the system checked, and both recorded in panto-cloud/docs/n4b-scope-addendum.md (Addendum 6). Neither changes this record’s decision. What they change is how much weight a green check earns: the verifier reported two passes on an App that could do nothing, because every check it ran tested for the absence of something bad and none tested for the presence of something required.

Not verified, and recorded as such: N2e’s rendered INSTALLATION_REGISTRY_MISMATCH message has never fired. No installation currently disagrees with the registry, and fabricating a disagreement to make a message print was judged to cost more than the confirmation is worth. The 422 the refusal keys off is captured verbatim; the text the operator would actually read is untested.

Step 1 is the arrangement this record’s fourth rejected alternative calls acceptable as a temporary bridge, not as the end state — the App private key lives in n8n (as PANTO_GH_APP_PRIVATE_KEY), which is precisely the cost named there. It is a sanctioned, time-boxed liability, not a design choice.

None of the broker’s required deletions are implemented by step 1: refusing to start when PETROVA_GITHUB_TOKEN is set, failing closed on empty discovery, and returning installation_id so a caller can assert it received an installation token rather than a PAT. Superseding this record on the strength of step 1 would discard all three.

Divergence opened by step 1 — ruled 2026-08-19, this record upheld

Section titled “Divergence opened by step 1 — ruled 2026-08-19, this record upheld”

As first built, step 1 did not follow the Decision’s “read:org is not requested; GET /installation/repositories supplies the repo list without it.” N3 and N5 entered through GraphQL organization(login:$login), which may require an organization permission — GitHub’s REST permission reference puts GET /orgs/{org} outside Metadata and is silent on the GraphQL org node. The open question was whether to move the harvest or widen the App with Members: Read-only.

Ruled: move the harvest. This record stands unmodified.

The reasoning is worth keeping, because it generalises. This record reached the narrower grant by deciding — it considered the enumeration path and found it sufficient. The GraphQL dependency arrived afterwards, as an implementation detail of a bridge this same record sanctioned only as temporary. Widening a permanent grant to accommodate the temporary thing lets the interim shape set the permanent boundary, which is the inversion this queue keeps finding. Grants are easy to widen later against a demonstrated need and near-impossible to narrow once something depends on them.

Landed at panto-cloud@b64bdbf: N3 lists GET /installation/repositories, a new N4c Repo index asserts the paginated listing against GitHub’s total_count and indexes repo node ids, N5 fetches metadata and file probes via GraphQL nodes(ids:) in batches of 40, and N7 asserts the harvest against the granted count. No organization node remains. The App is to be created with no organization permissions, after the harvest change deploys, so the grant is written against the running design.

A secondary consequence points the same way: GET /installation/repositories returns the installation’s actual repository set, which is what the coverage assertion needs anyway. The enumeration and the check now read the same number from the same source, where before the check compared against zero.

org_meta.name and .description are no longer available and are set null with an explicit reason field rather than silently. Nothing renders them.

The broker primitive already exists: cli/src/github-app.ts mints an App JWT, calls GET /app/installations, caches owner -> installation_id for 1h, and resolves per-installation auth. Its header comment states the motive verbatim: ‘The fleet spans many GitHub orgs… Onboarding a new org is then just install the petrova-act App on it.’

Two defects in that file must not be inherited. resolveAuthForOwner opens with const pat = process.env["PETROVA_GITHUB_TOKEN"]; if (pat) return {kind:"pat", token:pat}; — one env var silently collapses per-tenant isolation to a single union-blast-radius PAT with nothing observable changing. Below it, installationFromEnv supplies a single-installation fallback, and discoverInstallations catches all errors and returns an empty map (its catch comment names ‘App missing metadata permission’ as a cause), so a metadata regression degrades silently into minting one tenant’s token for a request about another.

Create a SECOND GitHub App, panto-observer, with default_permissions contents:read + metadata:read, no pull_requests, no issues, no administration, no workflows, and public: true for self-serve install. The absence of write permission is the security property. read:org is not requested; GET /installation/repositories supplies the repo list without it.

Do not reuse the petrova-act App: it is a write App, and giving the read-only observer a write-capable installation makes ‘we only call read endpoints’ a convention rather than isolation.

Mint per-installation tokens from an external broker in panto-core/broker/, porting cli/src/github-app.ts. n8n holds exactly one credential: a bearer for the broker. The App private key never enters n8n. The broker is also where admission is enforced — it looks the slug up in tenants.yaml and refuses TENANT_NOT_IN_REGISTRY at stage registry, the org-level analogue of REPO_NOT_IN_REGISTRY.

Required deletions in the broker: refuse to start if PETROVA_GITHUB_TOKEN is set; implement no env installation fallback and fail closed when discovery returns empty; return installation_id in every response so the caller can assert it received an installation token and not a PAT.

installation_id is DERIVED, never authored. tenants.yaml may carry it as a cached hint, and the broker must never trust the hint over discovery: an org that uninstalls and reinstalls gets a NEW installation id, and a stale hint would mint a token for whatever installation now holds that id.

Sequencing: ship an in-n8n JWT-signing bridge to unblock the App migration, ship the broker before tenant #10, delete the bridge.

Rejected: Blast radius is the union of all tenant orgs and the repo scope grants write, directly contradicting the write-surface: none declaration the system is built on.

Rejected: n8n 1.120.4 credentials are static records bound by id, so per-tenant selection at runtime needs 100 near-duplicate workflows or a 100-branch switch — the flow grows with the tenant list. It also cannot hold a 60-minute installation token, so it would mean storing PATs again.

Sign the App JWT inside an n8n Code node as the terminal design

Section titled “Sign the App JWT inside an n8n Code node as the terminal design”

Rejected: The App private key would live in n8n’s credential store alongside every other workflow on the instance, and the signing code is untestable outside n8n. Acceptable as a temporary bridge, not as the end state.

Reuse the existing petrova-act App for harvest

Section titled “Reuse the existing petrova-act App for harvest”

Rejected: petrova-act is write-capable and its key is already consumed by the write-verb CLI. Reusing it would place a write-capable installation behind a read-only observer, making I-1 a promise rather than a structural fact.

  • cli/src/github-app.ts:104-119
  • docs/panto/01-harvest-derive.json
  • docs/panto/README.md
  • docs/decisions/2026-08-17-a-shipped-credential-is-disclosed-not-stored.md
  • panto-cloud@f8f9c1e — step 1, the in-n8n JWT-signing bridge
  • panto-cloud@b64bdbf — harvest moved to GET /installation/repositories, upholding this record’s grant
  • panto-cloud:docs/n4b-scope-addendum.md — Addendum 2, the credential as actually deployed and the coverage defect it caused

Human merge required — this PR is opened with the awaiting-human-merge label and does not auto-merge.