Credentials: needs declared, supply chosen by deployment

Status: proposal, CRDM Phases 1–2 (needs, then the current process and its gap). It is a document only. No code is written against it, no secret has been created for it, and no secret value appears in it or anywhere in this change.

Rulings, owner, 2026-10-02 (about 06:40)

decisionrulinghow it was reached
D1 mechanismA: one GitHub App owned by the accountdefault applied. The owner said “no preference”, so this can be reopened
D2 namingA: capability names. (a) becomes PUBLISH_SITE_BOOTSTRAP and (b) becomes PUSH_BRANCH_TRIGGERING_CIdefault applied. The owner said “no preference”, so this can be reopened
D3 placementA: a credentials/ directory per instancedefault applied. The owner did not answer, so this can be reopened
D4 signingA: declare sign-artefact now, and build it after wallet-custody Q1 and Q7ruled by the owner

Four follow-up beans under epic 5a3l carry the work. None of them is built yet:

  • k3ml: the secrets skill, with the App setup walkthrough for a personal account
  • jzba: the secrets:check tool
  • 9hxd: the credentials/ declarations for bootstrap-tools and folio-assistant
  • 8mlt: the two secret renames
  1. Credentials: needs declared, supply chosen by deployment
    1. Rulings, owner, 2026-10-02 (about 06:40)
    2. 0. What was asked, and the short answer
    3. 1. What exists today: measured 2026-10-02
      1. 1.1 Every secrets.* reference, by repository
      2. 1.2 What could not be measured, said rather than skipped
      3. 1.3 Where these proposals already left the question
    4. 2. Need versus supply
      1. 2.1 The split
      2. 2.2 The action vocabulary, closed
      3. 2.3 The declaration, sketched
      4. 2.4 Where it lives, and which graph kinds
    5. 3. Profiles and mechanisms
      1. 3.1 The mechanisms, with their trade-offs
      2. 3.2 Profiles
    6. 4. Naming rule
      1. 4.1 The rule
      2. 4.2 Why BOOTSTRAP_PAGES_TOKEN fails it, three ways
      3. 4.3 Proposed names for (a) and (b)
    7. 5. Authenticating is not signing
      1. 5.1 The distinction
      2. 5.2 What is attributed, what is attested, today
      3. 5.3 Where provenance signing would fit
    8. 6. Agent boundaries, the skill and the tool
      1. 6.1 What an agent may and may not do
      2. 6.2 The tool: secrets:check (outline, not built)
      3. 6.3 The skill: secrets (outline, not built)
    9. 7. What this does not propose
    10. 8. The seven wallet-custody questions
    11. 9. Owner decisions
      1. D1: which mechanism supplies GitHub automation on the personal account?
      2. D2: adopt the capability naming rule, and rename (a) and (b) now?
      3. D3: where do needs, the profile and the registry live?
      4. D4: is provenance signing of published artefacts in scope now?
    12. 10. What would falsify this design
    13. Next step

0. What was asked, and the short answer

Owner, 2026-10-02, in the lead session:

  • Credentials need a design before any secret is created.
  • “keys have lots of deployment scenarios, and so will folio-assistant (personal, organizational)”.
  • A skill and a tool, so that humans and agents can add, rotate and revoke secrets.
  • The name BOOTSTRAP_PAGES_TOKEN is bad. Then the question: “what exactly is being signed?”

Nothing is being signed. BOOTSTRAP_PAGES_TOKEN is a bearer credential. It lets bootstrap-tools’ workflow authenticate a git push to the gh-pages branch of litlfred/bootstrap. GitHub checks it at the moment of the push and keeps no proof of it that anyone else could later check. Nothing in the pushed site carries a signature. The name made it easy to think otherwise. That is one of three faults in it, and §4 deals with all three.

The design in one paragraph: a workflow or tool declares the capability it needs (an action and a target, such as publish the site of litlfred/bootstrap), and says nothing about how that need is met. A deployment profile resolves each need to a mechanism: a GitHub App installation token, a fine-grained personal access token, a deploy key, a forge access token, or a keystore entry. The same need has different supply on a personal account, in an organization, on a sovereign forge and in a self-sovereign deployment. Secrets are named for the capability, so the name survives a change of mechanism. Authenticating and signing are separate needs with separate credentials. An agent reads needs and secret names, and never a value.


1. What exists today: measured 2026-10-02

1.1 Every secrets.* reference, by repository

Found by searching each repository’s .github/workflows/*.yml for secrets\.[A-Za-z0-9_]+. For folio-assistant the search ran on origin/main at b6a67e7dc20. For the others it ran on the remote default branch through the REST contents API.

secretwherepurposeif unset
GITHUB_TOKENfolio-assistant: 17 workflows; bootstrap-tools pages.yml; the three templates/paper workflowsautomatic. Lasts one job and is scoped to this repository by the job’s permissions: block. Used for PR comments, gh api, Pages deploys and GHCR pushesit is always present
BOOTSTRAP_PAGES_TOKENbootstrap-tools publish-bootstrap.ymlneed (a). Push the staged site onto litlfred/bootstrap’s gh-pages. GITHUB_TOKEN cannot push to another repository. The workflow’s header asks for a fine-grained token on litlfred/bootstrap with Contents read/writeunset. The first step fails with an ::error::, so the site is not published
MERGE_MAIN_TOKENfolio-assistant merge-main.ymlneed (b). Push the merge of main into PR branches that carry the merge-main label, so that the PR’s own CI runs on the push. A push made with GITHUB_TOKEN does not start workflowsunset. It falls back to GITHUB_TOKEN and then dispatches code-quality-gates.yml by hand
RELEASE_PLEASE_TOKENfolio-assistant release-please.ymlopens the release PR so that CI runs on it. The same reason as (b)falls back to GITHUB_TOKEN, which the workflow’s comment calls “half-broken”
NPM_TOKENsnappea_wasm.yml, hecke-engine-wasm.ymlnpm publish --provenance. The provenance part is OIDC (id-token: write). The token only authenticates the publishthe publish fails
ANTHROPIC_API_KEY, GEMINI_API_KEYagent-review.ymlmodel API calls for the review agentthe review step fails
HETZNER_API_TOKENdeploy-folio.ymlprovisions and updates the folio hostdeploy fails
GOOGLE_CLIENT_ID / _SECRET, GH_OAUTH_CLIENT_ID / _SECRETdeploy-folio.yml (passed into the host)the OAuth clients behind the auth gateway’s viewer login (Google) and collaborator login (GitHub). The two _ID values are public identifiers, not secretsthe login fails on the host
secrets.token_hexagent-review.yml:57not a secret. It is Python’s secrets module, inside a run: script—

There are two more on the host, outside any workflow, in cat-harness/deploy/ (only the names were read): COOKIE_SECRET, the HMAC key the auth gateway signs session cookies with, and FOLIO_API_TOKEN, the bearer token that bypasses the gateway (see the deployment-auth skill).

pyhecke-pypi-publish.yml and pyhecke-native-wheels.yml use no stored secret at all. They publish through PyPI Trusted Publishers over OIDC, and that is the shape this proposal prefers wherever the relying party supports it (§3.2).

1.2 What could not be measured, said rather than skipped

questionresult
which secret names are set on folio-assistant and bootstrap-toolscould not determine. GET /repos/{o}/{r}/actions/secrets returns 403 from this session’s proxy (“not permitted through this proxy”). “Unset” above is the lead session’s finding and the workflows’ own fallback messages. It was not re-measured here
workflows in litlfred/cat-harness, the tools layer, the DAK layer, the trust IG, the immunizations IGcould not determine. The API returns 403 because access to these repositories is not enabled for this session. The local litlfred/cat-harness* checkouts contain only .git
workflows in litlfred/bootstrapnone on the default branch (404). A local checkout still has a pages.yml, which publish-bootstrap.yml’s header says was retired (owner, 2026-10-01, #1770)
whether litlfred is an organizationno. owner.type is User. There are no organization secrets because there is no organization

Both “could not determine” rows matter for §6. A checker that has to list secret names needs permission to do so. That permission is a need in its own right, and §10 lists it as a falsifier.

1.3 Where these proposals already left the question

  • Wallet custody (#370, bean 61tg): the owner ruled that a self-sovereign harness may hold credentials, and set one rule: no secret is ever in the repository. It then asks seven questions. §8 maps this proposal onto them.
  • Deployment topologies (#363, epic 5a3l): ten axes and six modes. Credentials vary with the forge (axis 1), the network (axis 5) and the data stores (axis 9). They are not a new axis. They are a fact derived from those three, which is why the profiles in §3 are named after topologies.
  • Actors, ODRL and PROV-O: permissions are W3C ODRL 2.2 rules, and every task run is a prov:Activity. A credential need is ODRL-shaped (an action on a target), and §2.3 makes use of that.
  • Bean r0rq (completed): signing has two routes, an API signer and a human signer, and an actor’s network reach chooses between them. §5 places it.
  • Bean zakj (archived): check-secret-leaks.ts catches leaked tokens by their prefix. It answers “did a value reach a file?”, which is the opposite question to this one.

2. Need versus supply

2.1 The split

Each of the two measured needs has its mechanism written into the consumer. publish-bootstrap.yml says “a fine-grained personal access token”. Two things are wrong with that:

  1. The mechanism is a property of the deployment, not of the workflow. In an organization the same push should come from a GitHub App. On a GitLab forge there is no App and no fine-grained token. In a self-sovereign deployment with publication: local-server the need does not exist at all. A workflow that names its mechanism is wrong in three of the four profiles.
  2. The need is what an operator can reason about. “This workflow publishes bootstrap’s site” can be granted, refused, reviewed and audited. “This workflow wants a PAT” cannot be, because a PAT can do almost anything.

So:

  • A need is declared by whoever writes the consumer: an action, a target, and what happens without it.
  • A deployment profile is declared by whoever operates the deployment. It maps each need to a mechanism, a secret name and a grant (what the credential can actually do, which is usually more than the need).
  • A registry records when each supplied credential was issued and when it expires. It holds dates, never values.

2.2 The action vocabulary, closed

These are the actions found in §1.1, plus the one the owner asked about. The list is closed on purpose, so that a new action is a decision someone makes and not a string someone typed.

actionmeaningtoday
publish-siteput a rendered site where its host serves itneed (a)
push-branchupdate a branch, optionally triggersWorkflows: trueneed (b)
open-pull-requestopen or update a PR, optionally triggersWorkflows: truerelease-please
dispatch-workflowstart a workflow runmerge-main’s fallback
publish-packagepublish to a package registrynpm, PyPI
call-model-apiinference against a hosted modelagent-review
provision-hostcreate or update computedeploy-folio
authenticate-usersact as an OAuth client for a loginthe auth gateway
list-secret-namesread secret names and dates, never valueswhat secrets:check itself needs (§6.2)
sign-artefactbind a key to the digest of a published artefactnothing, today (§5)
sign-sessionMAC a session cookieCOOKIE_SECRET on the host

sign-artefact and sign-session are in the list so that signing is named as a separate action. It is never something a push token does on the side (§5).

2.3 The declaration, sketched

// <instance>/credentials/needs.json: authored beside the workflows it describes
{
  "$schema": "credential-needs/v1",
  "needs": [
    {
      "id": "publish-site-bootstrap",
      "action": "publish-site",
      "target": { "repository": "litlfred/bootstrap", "ref": "gh-pages" },
      "consumers": [".github/workflows/publish-bootstrap.yml#publish"],
      "whyNotDefault": "GITHUB_TOKEN cannot write to another repository",
      "without": "fail"            // fail | degrade | skip
    },
    {
      "id": "push-branch-triggering-ci",
      "action": "push-branch",
      "target": { "repository": "self", "ref": "pull-request heads labelled merge-main" },
      "triggersWorkflows": true,
      "consumers": [".github/workflows/merge-main.yml#merge"],
      "whyNotDefault": "a push made with GITHUB_TOKEN starts no workflow",
      "without": "degrade",
      "degradesTo": "dispatch-workflow code-quality-gates.yml"
    }
  ]
}
// <instance>/credentials/profile.json: authored by the operator of THIS deployment
{
  "$schema": "credential-profile/v1",
  "profile": "personal-github",                 // §3
  "supply": {
    "publish-site-bootstrap": {
      "mechanism": "github-app",                // or fine-grained-pat | deploy-key | forge-token | keystore
      "secret": "PUBLISH_SITE_BOOTSTRAP",       // the name the consuming step reads (§4)
      "grant": "contents:write on litlfred/bootstrap, every branch",
      "storedIn": "environment publish-site-bootstrap of litlfred/bootstrap-tools"
    }
  }
}
// <instance>/credentials/registry.json: written by the add / rotate / revoke steps
{
  "$schema": "credential-registry/v1",
  "entries": [
    { "secret": "PUBLISH_SITE_BOOTSTRAP", "mechanism": "fine-grained-pat",
      "issuedOn": "YYYY-MM-DD", "expiresOn": "YYYY-MM-DD",
      "rotateBy": "YYYY-MM-DD", "holder": "actor:owner" }
  ]
}

The three files hold no value, no hash of a value and no fragment of a value. A deploy key’s public fingerprint may be recorded, because it is public. Anything derived from a secret is excluded, because “it is only a hash” is exactly the argument that puts a guessable digest into a public repository.

What a need is in ODRL terms. It is an odrl:Request. The assignee is the consuming workflow (a mechanical actor, a prov:SoftwareAgent), with an action and a target. The profile’s supply entry is the grant that answers the request. So the declaration can be rendered as ODRL without changing the authored format, and an agent’s permission to dispatch-workflow a consumer can be checked against the same policy graph as every other permission. That is the Actors, ODRL and PROV-O model applied to mechanical actors.

2.4 Where it lives, and which graph kinds

One credentials/ directory per instance, declared in that instance’s <instance>.json like any other graph directory, holding three graph kinds. The single question in the content/context/state skill decides each of them:

filekind (proposed)holdswhy
needs.jsoncredential-needscontextNo process writes it. secrets:check and CI read it. It changes only when a person edits a workflow, which is an authoring act outside any process. Detach it and it empties: a need for a workflow that is gone says nothing, so it is not content (contrast policies, which stands on its own)
profile.jsoncredential-profilecontextRead by every check, and changed only when an operator changes the deployment. It is per-deployment in the same way publication.host is
registry.jsoncredential-registrystateThe add, rotate and revoke steps (§6.3) write it as they run. You would get it back by re-running them, not by authoring it again

Two constraints shape this.

  • The name needs is taken. cat-harness.json already has a needs field: the list of layers this instance sits on (bootstrap, bootstrap-tools). A credential need must never be written into it. Hence a directory named credentials/ and kinds named credential-*.
  • bootstrap-tools has to carry its own needs.json, because its workflow is the consumer. bootstrap-tools is a sibling of cat-harness and may not import it. The files therefore declare themselves with a "$schema" tag, as a workflow instance does, and are validated as data by a checker in the tools layer. A tag is a name, not an import. If the layering rule turns out to forbid even a tag naming a cat-harness format, the format belongs in bootstrap. That is falsifier 4 in §10.

3. Profiles and mechanisms

3.1 The mechanisms, with their trade-offs

mechanismexpirywhose identity actsblast radiusrotationstarts workflows?available on
GITHUB_TOKENend of jobgithub-actions[bot]this repository, limited by permissions:none needednoGitHub
OIDC to a relying party (npm or PyPI trusted publishing, Vault JWT auth, cloud federation, Sigstore)minutesthe workflow (repository + ref + workflow claim)whatever the relying party’s trust policy namesnothing to rotaten/aGitHub; GitLab (id_tokens); Forgejo varies
GitHub App installation token1 hour, minted per runthe App’s bot (<app>[bot]), not a personthe repositories the installation covers × the permissions requested. A single mint can be narrowed furtherrotate the App’s private key. An App may hold two keys at once, so rotation has no downtimeyesGitHub, including personal accounts
fine-grained PATchosen at creation. This design requires one (≤ 1 year)the person who created it. Pushes appear as themselected repositories × selected permissions, limited by what that person can doregenerate (new value), update the secret, record the new expiryyesGitHub
classic PAToptionalthe personevery repository the person can reach—yesexcluded by this design
deploy key (SSH)neverno person: the key belongs to one repositoryone repository, every branch, read or read/writeadd a new key, switch the secret, delete the old keyyesGitHub, GitLab, Gitea, Forgejo
forge access token (GitLab project or group access token; a Forgejo or Gitea bot user’s scoped token)GitLab: required, ≤ 1 year. Forgejo: optionala bot user the forge creates or the operator createsone project or group × scopes (write_repository …)rotate in the forge UI and update the CI variableyesself-hosted forges
GitLab deploy tokenoptionalnoneread-only for code. It cannot push——GitLab. Listed because the name suggests otherwise
forge secret store (repository, environment and organization secrets; GitLab masked + protected variables)——it is a place, not an identity. Environments release a secret only to the branches named and, optionally, after a required reviewer approves——GitHub (environments: every plan for public repositories; for private ones it depends on plan); GitLab
external vault (OpenBao or Vault, a cloud secret manager)can issue short-lived dynamic credentialswhatever it issuesper policythe vault’s lease and rotation—any. CI reaches it over OIDC, so CI stores no secret
OS keystore / hardware (macOS Keychain, libsecret, Windows Credential Manager, TPM, YubiKey, HSM)per keythe person or institution holding the devicethat one devicere-key—workstation; own infrastructure; air-gapped

Three rows decide most of the design.

  • GITHUB_TOKEN cannot start workflows and cannot leave its repository. Both measured needs exist only because of that. Any mechanism that is not GITHUB_TOKEN will meet them.
  • A fine-grained PAT is the owner acting. Every automated push made with one is recorded as the owner’s own push. That mixes a person’s acts with a machine’s in the audit trail that PROV-O (§5) will later read. An App or a deploy key keeps them separate.
  • A deploy key never expires. For an operator that is convenient. For a checker it is a problem: there is no date to warn about. The registry therefore gives it a rotateBy policy date instead.

3.2 Profiles

A profile is a default mechanism for each class of need, and any need can override it. It is a deployment fact derived from the topology axes, not a new axis.

need classP1 personal GitHub (today)P2 GitHub organizationP3 sovereign / self-hosted forge (no Apps)P4 self-sovereign (wallet)
push to another repository (a)owner-owned GitHub App, installed on selected repositories; or a fine-grained PAT; or a deploy keyorg-owned App; App ID and key as organization secrets visible to the selected repositoriesforge access token for a bot user, or a deploy key with write access; protected CI variableusually none: with publication: local-server the site is served from the checkout. Otherwise an SSH key held in the OS keystore or on hardware
push or PR that must start CI (b)the same App (its token starts workflows)the same Appthe bot user’s token. Whether a forge-CI push starts pipelines varies by forge and version, so measure itnone: the merge is local and the gates run locally
publish a packageOIDC trusted publishing (npm, PyPI). Delete the stored tokenthe sameOIDC if the registry supports it; otherwise a registry token in a protected variablea local registry, or none
call a hosted modela repository secret, in an environmentan organization secret plus an environmenta protected CI variable or the vaultkeystore; or none (open-weight-local)
provision or deploy a hosta secret in an environment with a required reviewerthe same, with org-level reviewersthe vault over OIDCnot applicable: own hardware
sign an artefactSigstore keyless over OIDC (public repositories)the same, or an org key in a KMSan institution key in an HSM or KMSan institution key in the wallet / HSM, through the human route of bean r0rq when air-gapped

Why the App is the P1 recommendation even though the account is personal. An App can be installed on a personal account. One App covers (a), (b), release-please, the future smart-* repositories and the cat-harness seeding, so the setup is paid for once. It is the only GitHub mechanism whose working credential lasts an hour. It acts as itself, not as the owner. And it transfers to an organization unchanged if the account ever becomes one, which moves the deployment from P1 to P2 with nothing renamed. Its costs are a one-time registration (about ten minutes in the UI, §6.3), and a long-lived private key that must itself be stored and rotated.

Narrowing the App’s blast radius on litlfred/bootstrap. A token with contents: write can write every branch, not only gh-pages. A ruleset on main that leaves the App off its bypass list reduces the effective grant to what the need actually is. The profile records the real grant, and secrets:check reports grant broader than need when no such ruleset exists (§6.2).


4. Naming rule

4.1 The rule

A secret that grants a capability is named for the capability: VERB_OBJECT[_TARGET]. The target is left out when it is the repository the secret lives in. A secret never names the mechanism (_TOKEN, _PAT, _KEY, _APP) and never the consumer (a workflow’s name).

Secret names are an operator’s interface. When a name says what the credential lets someone do, the operator can see what is lost if it leaks and what breaks if it is revoked. A name that says how the credential was made goes stale the day the mechanism changes. A name that says which workflow uses it goes stale the day a second workflow needs the same capability.

One exception, stated as such: key material that is an identity rather than a capability is named for the identity. A GitHub App’s private key creates tokens for many capabilities, so naming it after one of them would be false. It is HARNESS_BOT_PRIVATE_KEY, and the App’s ID, which is public, is a repository variable, HARNESS_BOT_ID, not a secret.

The consuming step reads the capability name in every profile. Under P1 with a PAT, PUBLISH_SITE_BOOTSTRAP is a stored secret. Under the App, a mint step (actions/create-github-app-token, repositories: bootstrap, permission-contents: write) produces a token, and the job exposes it as PUBLISH_SITE_BOOTSTRAP. The step that pushes does not change. §10 records where that invariance breaks down (SSH compared with HTTPS).

4.2 Why BOOTSTRAP_PAGES_TOKEN fails it, three ways

  1. TOKEN names the mechanism. It is wrong the moment a deploy key or an App supplies the capability.
  2. BOOTSTRAP reads as a verb. “Bootstrap the pages” sounds like setting something up or signing it, not like publishing to a repository named bootstrap. That is where “what is being signed?” came from.
  3. It does not say what it allows. “Pages” leaves out the fact that the credential can write to litlfred/bootstrap, and today that means every branch.

4.3 Proposed names for (a) and (b)

(a) publish-site @ litlfred/bootstrap(b) push-branch @ self, triggersWorkflows
todayBOOTSTRAP_PAGES_TOKEN (unset)MERGE_MAIN_TOKEN (unset)
P1 personal, App (recommended)job-level env PUBLISH_SITE_BOOTSTRAP, minted from HARNESS_BOT_PRIVATE_KEY + HARNESS_BOT_IDjob-level env PUSH_BRANCH_TRIGGERING_CI, minted from the same App
P1 personal, PAT or deploy keyrepository secret PUBLISH_SITE_BOOTSTRAP in bootstrap-tools, inside environment publish-site-bootstrap (deployment branch: main)repository secret PUSH_BRANCH_TRIGGERING_CI in folio-assistant
P2 organizationas the App row. HARNESS_BOT_PRIVATE_KEY becomes an organization secret visible to the selected repositoriesas the App row
P3 sovereign forgeprotected, masked CI variable PUBLISH_SITE_BOOTSTRAP (a bot user’s token, or a deploy key). Note that on GitLab, publish-site is a Pages job artifact, not a branch push, so the need stays the same and the mechanism changesprotected CI variable PUSH_BRANCH_TRIGGERING_CI, once it is measured that a push by the bot starts pipelines on that forge
P4 self-sovereignusually no secret: local-server serves the checkout. Otherwise a keystore entry labelled folio/PUBLISH_SITE_BOOTSTRAPno secret: the merge and the gates run locally

Renaming these two costs nothing, because neither is set. The other secrets in §1.1 are set and working. This repository’s own rule, “don’t encode rules against a working setup”, says the naming rule applies to new secrets and to an existing secret when its consumer is next changed, and never as a sweep. For the record, the rule would give RELEASE_PLEASE_TOKEN → OPEN_PR_TRIGGERING_CI, HETZNER_API_TOKEN → PROVISION_HOST_HETZNER, and NPM_TOKEN → deleted, replaced by trusted publishing.


5. Authenticating is not signing

5.1 The distinction

authenticatingsigning
answerswho is making this request, now?who vouches for these bytes, for good?
checked bythe service, at request timeanyone, later, offline, with the public key
leavesan attribution: a line in the forge’s log, which the forge can edit or lose and which the forge alone assertsan attestation: a signature over a digest that travels with the artefact
survives the forge?noyes
credentialbearer token, SSH key, OIDC tokenprivate key: a held key, or a keyless short-lived certificate

Keys have a third use, encryption (confidentiality). Nothing here needs it, and wallet custody (wallet-custody Q3) will. It is named so that it is not later mistaken for one of the other two.

Rule: a credential that authenticates never signs, and the other way round. They are separate needs with separate names, separate holders and separate rotation. A leaked push token must not be able to forge provenance. A signing key must not be lying in a CI secret store where every workflow on the branch can read it.

5.2 What is attributed, what is attested, today

artefactauthenticated byattributed tosigned?
bootstrap’s site on gh-pages (need a)the future PUBLISH_SITE_BOOTSTRAPthe commit says github-actions[bot]. Commit author fields are free text, so this is a claim, not evidenceno
merge-main’s pushes (need b)GITHUB_TOKEN todaythe sameno
PRs merged in the GitHub UIthe owner’s sessionthe owneryes, by GitHub’s web-flow key (measured: b6a67e7dc20 carries a gpgsig). It attests that GitHub made the commit for that account, not that the content is good
agent commitsthe session’s push credentialthe commit’s author textno (measured: git log --format=%G? gives N)
npm packages (hecke-engine-wasm, snappea-wasm)NPM_TOKENthe npm accountyes: Sigstore provenance over OIDC. It attests which repository, workflow and commit built the package
PyPI wheels (pyhecke)OIDC trusted publishingthe workflowattestations not measured here
the JSON-LD graph export (export-graph.ts --provenance)—prov:wasDerivedFrom and prov:generatedAtTime inside the fileno. These are PROV claims, and anyone who can write the file can write them
session cookies on the folio host——MAC’d with COOKIE_SECRET. That is a symmetric key, so only the gateway itself can check it. It authenticates sessions to the gateway and is not an attestation anybody else can verify
the published docs site, QA sidecars, beans, releases——no

So, plainly: the harness signs nothing it publishes. The only signatures on the published path are made by third parties: GitHub’s web-flow key on UI merges, and Sigstore on npm packages.

5.3 Where provenance signing would fit

sign-artefact is a need like any other. What would be signed is not the site or the files themselves. It is a statement: an in-toto / SLSA provenance document, or a W3C Verifiable Credential. Its subject is the digest of the published artefact (the staged site tree, the JSON-LD graph, an IG package). Its predicate is the prov:Activity that produced it, which is the task run the Actors, ODRL and PROV-O proposal already records. The signature then attests: this key holder asserts that this activity produced these bytes. A GDHCN-style trust chain can consume that, and an unsigned prov:wasDerivedFrom cannot.

The mechanism depends on the profile (§3.2):

  • P1 / P2: Sigstore keyless, from the publishing workflow’s OIDC identity. There is no key to hold. It attests the workflow, not an institution.
  • P3 / P4: an institution key. This is wallet-custody Q1’s “institution’s signing keys”. It sits in an HSM, a KMS or the wallet, and bean r0rq gives it two routes: the API signer when the signing actor has network reach, and a human signer when it does not.

The difference between the two bullets is who vouches. “This CI run built it” and “this ministry endorses it” are different claims. A deployment’s profile says which one it makes.


6. Agent boundaries, the skill and the tool

6.1 What an agent may and may not do

an agent mayan agent never
read needs.json, profile.json and registry.jsonsees, asks for, accepts, echoes, stores or forwards a secret value
read secret names and their updated_at, when it is permitted to (§1.2: from this session it is not)runs gh secret set, gh secret delete or their forge equivalents
compare needs, workflow usage, names present and the registry, and report the resultcreates a PAT, an App key, a deploy key or a forge token
remind the holder before something expires: in the session-start sweep, on the health issue, in a beanrotates or revokes on its own initiative (deletion-requires-confirmation: it reports what would go and waits)
draft the exact human steps (§6.3) for the person to carry outwrites anything derived from a value (a hash, a prefix, a length)
dispatch a workflow that uses a secret, if an ODRL rule grants it dispatch-workflow on that consumertreats “the person pasted it into the chat” as permission to use it

If a value is pasted into a chat, the agent says that the value is now exposed: it sits in a transcript, and possibly in a provider’s logs. It recommends rotation using the steps in §6.3, and it does not use the value. The leak scanner from bean zakj covers the case where the value reaches a file. This rule covers the case where it reaches a conversation.

Who holds each mechanism. Add, rotate and revoke belong to the holder, always a person:

mechanismholder
App, fine-grained PAT, deploy key, repository secret, environment (P1)the account owner
App, organization secret, environment reviewers (P2)an organization owner. Reviewers are named per environment
forge token, protected CI variable (P3)a forge maintainer of the project or group
keystore, HSM, wallet key (P4)the device holder or the institution’s custodian. Wallet-custody Q4 governs it

6.2 The tool: secrets:check (outline, not built)

Inputs, all names-only: every instance’s needs.json; every workflow’s secrets.NAME references, excluding non-context matches such as secrets.token_hex (§1.1 found one); the names present, from /repos/{o}/{r}/actions/secrets, /environments/{env}/secrets and /orgs/{org}/actions/secrets (none of these return values); and registry.json.

Findings:

findingexample todayseverity
declared, used, not presentBOOTSTRAP_PAGES_TOKENmajor when without: fail, minor when degrade
used, not declaredevery secret in §1.1, until needs.json existsmajor
declared, not used—minor
present, not declared (orphan)unknown, could not listreported, never deleted
name breaks the naming rule*_TOKEN namesadvisory for existing secrets (§4.3)
expires within 30 days / 7 days / expired—minor / major / critical
deploy key past rotateBy—minor
grant broader than needan App with contents: write on bootstrap and no ruleset on mainminor
one credential both authenticates and signs—critical (§5.1)
could not determinethe 403 in §1.2outranks clean, exit 2

It behaves like check-secret-leaks.ts and check:ci-health: exit 0, 1 or 2, where 2 means “could not check” and is never shown as green. It feeds bun run health as one check, and each threshold carries its basis: 30 days gives the owner one ordinary monthly cycle, and 7 days gives one working week. It is exposed as both an MCP tool and a bun run script, to keep axis 6’s parity.

It has a need of its own. Listing secret names needs permission to read secrets metadata, and GITHUB_TOKEN does not have it. Its need is list-secret-names @ <each repository>, and in P1/P2 the App supplies it (Secrets: read-only). The checker declares that need in the same file as everything else.

6.3 The skill: secrets (outline, not built)

Placement: cat-harness/skills/sdlc/sdlc-core/secrets.md, beside ci-health and deployment-auth, registered with bun run skill:register. The processes would be processes/credential-add.bpmn, credential-rotate.bpmn and credential-revoke.bpmn, each with a human lane for every step that touches a value and an agent lane that checks and writes the registry. The skill, the schemas and the declarations go in cat-harness. The checker’s code goes in the tools layer.

Its sections, with the GitHub UI paths the skill would carry:

  • Fine-grained PAT. Add: avatar → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token. Set an expiration. Repository access → Only select repositories → the target. Repository permissions → Contents: Read and write. Then in the consuming repository: Settings → Secrets and variables → Actions → New repository secret, or Settings → Environments → the environment → Add environment secret. Rotate: open the token → Regenerate token, update the secret, and the agent records the new expiresOn. Revoke: delete the token, then delete the secret.
  • GitHub App. Add: Settings → Developer settings → GitHub Apps → New GitHub App. Webhook off. Repository permissions limited to what the declared needs require. “Only on this account”. Then Generate a private key (a .pem downloads), and Install App → Only select repositories. Store the App ID as a variable and the key as a secret. Rotate: generate a second key, update the secret, then delete the old key. Revoke: delete the key, or suspend or uninstall the installation.
  • Deploy key. Add: the person generates a key pair locally (ssh-keygen -t ed25519 -N "" -C publish-site-bootstrap -f <tmp>). In the target repository: Settings → Deploy keys → Add deploy key → Allow write access, with the public half. The private half goes into the consuming repository’s secret. Then shred the local file. Rotate: add the new key, switch the secret, delete the old key. Revoke: delete the key.
  • Environment. Settings → Environments → New environment → Required reviewers; Deployment branches and tags → Selected → main.
  • Trusted publishing (replaces NPM_TOKEN). npmjs.com → the package → Settings → Trusted Publisher → GitHub Actions (owner, repository, workflow file). Then delete the stored token.
  • Forge (GitLab). Project → Settings → Access tokens → Add new token (role, write_repository, expiry). Then Settings → CI/CD → Variables → Masked + Protected.
  • Keystore. security add-generic-password … or secret-tool store …, with the value typed at the prompt and never put on a command line, where it would land in the shell history.

7. What this does not propose

  • No secret is created, renamed or deleted by this change, or by any agent.
  • No workflow is edited. Renaming (a) and (b) in their workflows is follow-up work after decision D2.
  • No credential for a person’s health data. That stays wallet custody’s subject.
  • No live-store connection in sovereign cloud. That is still “artefacts only” (owner, 2026-09-24, bean 4y2i).
  • No historical secret sweep. Bean zakj ruled it out of scope with its reason.

8. The seven wallet-custody questions

#questionthis proposal
Q1whose credentials?Adds a third class: deployment credentials, the automation identities that push, publish and provision. These are distinct from a person’s health credentials and from an institution’s signing keys. Institution signing keys are what supplies sign-artefact in P3 / P4 (§5.3). A person’s own credentials: open
Q2hold or broker?Answered for deployment credentials: broker, always. The harness holds names and dates, and the forge store, vault or keystore holds the values. For signing in P4, the human route brokers too. For a person’s credentials: open
Q3where do the bytes live?Answered for deployment credentials (per profile, §3.2), and in a way that does not need the “present but never published” materialization state: the repository holds only needs, profile and registry, and none of them contains a secret. For wallet contents: open
Q4who may act with it?Partly answered. An agent never handles a value (§6.1). It may cause a use only by dispatching a declared consumer under an ODRL grant. Whether an agent may ever present a person’s credential: open
Q5auditPartly answered. Every use is a workflow run in the forge’s log. Every add, rotate and revoke is a commit to registry.json. Every signature is a prov:Activity. A custody log for person credentials: open
Q6air-gapped operationPartly answered. Expiry warnings work offline from the registry’s dates. Revocation status checks (status lists, OCSP) need the network and stay open
Q7which standards?Left open. This proposal names candidates for the signing layer only: in-toto / SLSA provenance, Sigstore, W3C VC Data Integrity, and HCERT / GDHCN for health. None of them belongs in bootstrap

9. Owner decisions

There are four, and each is complete in itself. What happens if the owner says nothing is stated, and it is what an agent would then do.

D1: which mechanism supplies GitHub automation on the personal account?

What changes: who appears to make the automated pushes, how often anything expires, and how much setup the owner does once.

optiondoesprocondownstreamreversible
A. One owner-owned GitHub App (recommended)one App, installed on selected repositories, tokens minted per runhour-long tokens; acts as a bot, not as the owner; one setup covers (a), (b), release-please, smart-* and the cat-harness seeding; moves to an organization unchangedabout ten minutes of UI setup; a long-lived private key to store and rotateevery workflow gains a two-line mint stepyes: uninstall the App
B. A fine-grained PAT per needone token per needfamiliar; no Appevery push is recorded as the owner’s; one token per need to rotate; expiries break workflows quietly unless they are checkedsecrets:check becomes essential rather than usefulyes
C. Deploy keysone SSH key per target repositorynothing expires; no person identitygit only: no API, so it cannot open a PR (release-please) or list secret names (§6.2); one key per repository; no expiry for a checker to seeSSH plumbing in the steps, and a second mechanism for everything API-shapedyes

Recommendation: A, for the reasons in §3.2. If you say nothing: the skill documents A first. No secret is created. publish-bootstrap.yml stays red and merge-main.yml stays on its dispatch fallback, because both outcomes need you and not an agent.

Question: should the harness’s GitHub automation use one GitHub App owned by your account (A), a fine-grained token per need (B), or deploy keys (C)?

D2: adopt the capability naming rule, and rename (a) and (b) now?

What changes: the secret names you create in the UI, and the text of two workflows.

optiondoesproconreversible
A. Adopt; rename (a) → PUBLISH_SITE_BOOTSTRAP and (b) → PUSH_BRANCH_TRIGGERING_CI now; others when next touched (recommended)§4the names you create first are already right; renaming costs nothing while both are unsettwo workflow edits, in two repositoriesyes
B. Adopt for new secrets only; keep the two existing names—no workflow editsthe first two secrets you create carry the names you called badyes
C. Do not adopt——names keep encoding mechanism and consumer—

Recommendation: A. If you say nothing: A, carried out as a follow-up PR that edits the two workflows. No secret is touched.

Question: should new secrets be named for the capability they grant, with (a) and (b) renamed now while neither is set?

D3: where do needs, the profile and the registry live?

What changes: what an agent reads to find out what credentials a repository needs, and what the checker validates.

optiondoesproconreversible
A. A credentials/ directory per instance; three kinds: context, context, state (recommended)§2.4a need lives beside its consumer; the graph kinds are classified by the skill’s own question; bootstrap-tools carries its ownthree new graph kinds to register; the validator reads bootstrap-tools’ file as datayes
B. Comments inside each workflow’s YAML—nothing new to declareno schema, nothing to check against, no ODRL renderingyes
C. One central registry in cat-harness for every repository—one filecat-harness would describe repositories above and beside it, which reverses the dependency directioncostly to undo

Recommendation: A. If you say nothing: A, as the follow-up bean’s design, with the kinds proposed and not registered until that bean is built.

Question: should each instance declare its credential needs, profile and expiry registry in its own credentials/ directory?

D4: is provenance signing of published artefacts in scope now?

What changes: whether bootstrap’s site and the JSON-LD graph carry a signature anyone can check, and whose.

optiondoesproconreversible
A. Declare sign-artefact now; build it later, after wallet-custody Q1 and Q7 (recommended)the need and the “authenticate ≠ sign” rule are recorded; nothing is signed yetno premature commitment to a standard; the separation is fixed before any signing credential existsthe published artefacts stay unsigned for nowyes
B. Sign now with Sigstore keyless on GitHubthe workflow signs the site and graph digestseasy; there is no key to holdattests the CI run, not an institution; may need redoing when Q1 settlesyes
C. Out of scope——the separation rule has nothing to attach to—

Recommendation: A. If you say nothing: A.

Question: should signing of published artefacts be declared as a need now and built after wallet custody’s Q1 and Q7 are decided (A), done now with Sigstore keyless (B), or left out (C)?


10. What would falsify this design

  1. Need-invariance breaks. The claim is that a consuming step reads one capability name whatever the mechanism. It already fails at the edges: a deploy key is presented over SSH and a token over HTTPS. A small presentation wrapper (use-capability) is meant to absorb that. If the wrapper needs a branch for every forge × mechanism pair, the mechanisms are not interchangeable, and names should carry a mechanism class after all.
  2. Presence can never be determined where it matters. If listing secret names stays forbidden in agent sessions (it is today, §1.2) and also turns out to be unavailable to CI under every profile, then secrets:check’s third input becomes “could not determine” forever. The check would shrink to needs against usage against registry, and the design should say so rather than keep a permanent unknown.
  3. Profiles are not discrete. If real deployments mix profiles routinely (a personal repository supplied by an organization’s App, say), then “profile” is only a per-need mapping with a default, and its name overstates it. The schema already allows a per-need override, so this would cost a rename, not a redesign. It is falsified only if the overrides come to outnumber the defaults.
  4. The layering refuses the tag. If bootstrap-tools may not carry even a "$schema": "credential-needs/v1" file, because naming a cat-harness format counts as pointing upward, then the format belongs in bootstrap and §2.4 is in the wrong place.
  5. The registry drifts. Expiry dates are entered by hand, because a repository-scoped API cannot see a PAT’s expiry. If measured drift shows the registry is wrong more often than right, its warnings are noise. Then the recommendation must move to mechanisms with nothing to expire (App tokens, OIDC), and away from tracking dates.
  6. A use of a key that is neither authentication nor signing turns up and is not encryption. If so, §5’s three-way split is incomplete.

Next step

The CRDM process continues from Phase 1. The needs statement and the current process with its gap (§1, §2.1) go on issue #363, because epic 5a3l lives there. A separate issue is opened only if the owner wants one, since an agent does not create issues unasked. After D1–D4 are decided, Phase 3 writes the requirements, and the follow-up beans (skill, checker, three graph kinds, two workflow renames) are opened then and not before. When the feature ships, this page moves to docs/requirements/.


folio-assistant — code under the Apache License 2.0, documentation under CC BY 3.0. Generated schema and API references are rebuilt from source on every push to main.

deployed dev