A person's write access is read from the collaborator permission, never from an association; trial P-47 passes (#222, #231)

Merged
DO
7d ago
+1763-41

Trial P-32 failed for the permission: the reader App reads the org owner's authorAssociation as CONTRIBUTOR, where the Maintainer's own login reads MEMBER. So a person's write access is read from the repository's collaborator permission, GET /repos/{repo}/collaborators/{login}/permission, and never from an association. admin, maintain and write count; triage, read and none do not. The Maintainer answered on 2026-10-21 that triage is not write access. Trial P-47 shows the reader App can make that read, and with what grant.

What changes

  • Trial P-47 passes. trials/P-47.json is built from the Maintainer's run of tools/trial_reader_reads.sh on 2026-10-21, from its output folder alone.

    • It holds two installation tokens for the reader, each narrowed to one grant on the canary, ten reads, and both revocations (204).
    • With either grant the reader read admin for don-petry, write for Joaolfelicio (named with the Maintainer's consent), write for the machine user (role_name maintain), and read for octocat. It also read the activity of trial/p-9.
    • The triage role was not tried: the canary has none, and the Maintainer chose to run without one.
  • Each read's grant is the one GitHub's own x-accepted-github-permissions names. That is metadata=read for the permission read and contents=read for the activity read. The canary is public, so a token with metadata alone read the activity all the same; a private repo would refuse it.

  • config/apps.json names each grant. read:facts.actor.can_write sits under metadata: read for the reader. read:facts.last_pusher moves from pull_requests to contents: read for the reader and the Controller (#231). No App's grant changes, so P-39's fixture still holds, as tools/check_apps.py checks. tools/check_apps.py also accepts a property of a definition of the facts as a need, such as actor.can_write.

  • Which field the port reads, and why. The port (adapters/timeline.py, #272) reads GitHub's permission field, not role_name.

    • permission folds maintain into write, as the machine user's answer shows, and GitHub documents that it folds triage into read. So it gives the ruling's reading directly, and any custom role GitHub folds into one of its five values.
    • role_name carries maintain, triage and custom roles by their own names. Reading it would defer on an org with custom roles.
    • A test holds the machine user's case, permission write with role_name maintain, and holds the port to the fixture.
  • The issue world. sim/issues.py serves each login's permission as GitHub does, and builds can_write with its own copy of the rule. It never imports the port, and a test holds both copies to the ruling's three words.

    • The grounding rule a_logins_permission_is_read_from_the_collaborator_endpoint replaces an_issues_author_has_an_association, and is grounded by trials/P-47.json.
    • tests/test_adapters.py::test_what_each_credential_is_shown_of_a_permission_is_recorded is held now: its @pending is off, and L-57's check names it.
  • The trial script. It refuses before minting anything when:

    • OUT exists, or resolves inside the repo through any link;
    • the key cannot be read;
    • the repo is the Factory's own, or not the reader's canary;
    • a login or branch has a bad shape.

    It also reads no .curlrc, redacts every answer before OUT, and revokes every token on every path, including a mint cut short and a signal. Each of these has a test against a stand-in GitHub, and a mutant.

  • The Kernel does not change. By the working ruling, can_write stays a boolean the port computes, and the Kernel already defers on one it cannot read.

  • Documents. Decision 0046 (renumbered at enqueue if taken), the P-32 and P-47 rows, the batch line, and the port's contract in §3.

Not covered here

  • The issue world's other actors. In sim/issues.py, the label actors, reopeners and editors still carry a fixed can_write from sim/world.py; only the author's comes from the permission. Filed as #299, "Issue world: every actor's write access is built from the permission it serves, not the author's alone", under #182.

Reviews and verification

  • Export verify. make verify passed on a fresh git archive export with PATH=/usr/bin:/bin SIM_SEEDS=40.
  • Mutants. Each new mutant was applied by hand on a scratch copy and killed by its tests, except one. The mutant for the signal path survives on macOS, whose sh runs the exit trap on SIGTERM anyway. CI's sh is dash, which does not, so CI's shards kill it.
  • Claude review (fresh context). Two majors and minors, answered in the last commit. The majors: the activity read was mapped against GitHub's header, and no test failed if the trial script's redaction was skipped.
  • Security pass. The same mapping major, plus hardening: a mint cut short, a curl configuration, the fixture's records. All are answered.
  • AGY (Gemini 4 Argon). No findings.
  • Muse (Muse Wonder Full). The redaction gap as a major, plus the symlink, the signal path, the mint cut short and the mutants' heading. All are answered.

Closes #222, Closes #231

🤖 Generated with Claude Code