feat: gitvs repo_collaborators resource — census, class N, gated effector - #1423
Conversation
…ctor The partner-partition enforcement loop: estate.yaml gains the repo_collaborators resource (approval-gated delegate, so reconcile never auto-runs it); observe() gains a bounded collaborator census (granted ∪ never-grant repos, affiliation=outside, plus each org's outside-collaborator roll); doctor class N diffs the census against the ACCESS registry — undeclared access or an over-ceiling role is RED, a declared-but-absent grant is a staged invite cited on L-PARTNER-GRANTS (outbound stays his hand). collab-sync.py is the effector: --plan prints the exact commands, --apply removes UNDECLARED access only, double-dark behind LIMEN_COLLAB_APPLY=1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
Multi-agent review roll call (CodeRabbit and Claude review automatically. Reviewers: post substantive findings only. Authors/agents: address every thread, push fixes to this branch, reply and resolve, then re-request review.) |
|
To use Codex here, create a Codex account and connect to github. |
📝 WalkthroughWalkthroughAdds a governed ChangesCollaborator access governance
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Operator
participant collab-sync
participant gitvs
participant GitHub
Operator->>collab-sync: run --plan or --apply
collab-sync->>gitvs: load ACCESS and collaborator census
gitvs-->>collab-sync: live collaborators and declared grants
collab-sync->>GitHub: DELETE undeclared access when apply gate is enabled
collab-sync-->>Operator: staged role and invitation commands
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 Checkov (3.3.8)his-hand-levers.jsonTraceback (most recent call last): institutio/github/access.yamlTraceback (most recent call last): institutio/github/estate.yamlTraceback (most recent call last):
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fba0d678d6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| grants = access.get("grants") or {} | ||
| policy = access.get("policy") or {} | ||
| probe = sorted({str(r) for r in grants} | {str(r) for r in (policy.get("never_grant_repos") or [])}) |
There was a problem hiding this comment.
Scan all repos for partner overexposure
Because the probe set is only grants ∪ never_grant_repos, class N never queries other owned repos. If a login is correctly declared for repo A but accidentally still has outside-collaborator access on repo B, the org roll suppresses it as a declared login and repo B is absent from by_repo, so both doctor and collab-sync --apply can report/act as if drift is clean while the extra access remains. The new desired state says each partner sees exactly their granted project repos, so the census needs to enumerate all governed repos (or otherwise enumerate every declared collaborator's actual repo access) before declaring green.
Useful? React with 👍 / 👎.
| pending = {str(i.get("login") or "").lower() for i in (obs.get("invitations") or [])} | ||
| for login_l, g in sorted(declared.items()): | ||
| if login_l in live: | ||
| continue | ||
| if login_l in pending: | ||
| cites.append(f"[N collaborator-drift] {repo}: invite pending for {g.get('login')} → {n_atom}") |
There was a problem hiding this comment.
Validate pending invitation roles
Pending invitations are reduced to login strings here, discarding the role captured by the census. If a declared partner has a pending invite at push/admin while access.yaml declares only pull, class N cites the invite as acceptable instead of failing or staging a cancellation/downgrade, leaving an over-privileged invite ready to be accepted. Compare pending invitation permissions through ROLE_NAME_TO_GRANT the same way live collaborator roles are checked before treating the grant as satisfied.
Useful? React with 👍 / 👎.
| "api", | ||
| f"/repos/{repo}/collaborators?affiliation=outside&per_page=100", | ||
| "--jq", | ||
| "[.[] | {login: .login, role: .role_name}] | sort_by(.login)", |
There was a problem hiding this comment.
Paginate collaborator census calls
These REST list calls set per_page=100 but do not pass --paginate; the gh manual says --paginate is what makes additional requests for all pages. For any repo/org with more than 100 outside collaborators or invitations, entries after page 1 are invisible, so undeclared or over-ceiling access can be missed and class N can go green incorrectly. Apply pagination consistently to the repo collaborators, invitations, and org outside-collaborators calls and merge the pages before diffing.
Useful? React with 👍 / 👎.
| # Collaborator census — the partner-partition layer (class N's input; ACCESS is desired-state). | ||
| led["collaborators"] = _collaborator_census(estate, load_access(), token, online) |
There was a problem hiding this comment.
Don't publish undeclared collaborator logins
The census result is written into docs/github-estate-ledger.json, so putting raw collaborator and invitation logins in led["collaborators"] also publishes any undeclared login discovered during drift detection, not just the declared rows already in access.yaml. In the scenario this rung is meant to catch — unexpected outside access — the first gitvs census would commit that person's login into the tracked ledger; keep the detailed collaborator facts in a gitignored runtime sink or redact to counts in the public ledger.
Useful? React with 👍 / 👎.
| try: | ||
| # personal accounts 404 here — degrade to None; class N skips that roll, never guesses | ||
| out["org_outside"][org] = json.loads(r.stdout or "[]") if r.returncode == 0 else None |
There was a problem hiding this comment.
Treat unreadable org rolls as incomplete
Only the per-repo calls affect ok, so if /orgs/{org}/outside_collaborators fails while the repo probes succeed, complete remains true and collab-sync later treats None as an empty list. With an App/token that can read the probed repos but not the org outside-collaborator roll, collab-sync can return a clean verdict without checking org-level undeclared collaborators; mark these failures incomplete for the effector, or make the effector refuse when any org roll is unreadable.
Useful? React with 👍 / 👎.
| print(cmd) | ||
| if his_hand: | ||
| print(f"→ {len(his_hand)} staged command(s) above are L-PARTNER-GRANTS work — fire them deliberately.") | ||
| return 1 |
There was a problem hiding this comment.
Return success after successful applies
When --apply is armed and the only drift is machine-runnable removals, this always exits 1 even if every gh DELETE succeeds because failed is accumulated but never used. Automation that invokes the effector will keep treating an already-fixed collaborator cleanup as failed instead of recording a green receipt; return nonzero only when a deletion failed or human-staged commands remain.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@scripts/gitvs.py`:
- Around line 1643-1702: Scope declared collaborator logins by owning
organization rather than using one global set, so cross-org grants do not
suppress undeclared-collaborator findings. In scripts/gitvs.py lines 1643-1702,
update the N-rung’s declared-login computation and org_outside comparison to use
each repository’s organization; apply the identical scoping in
scripts/collab-sync.py lines 107-115 for the --apply removal path. Reuse a
shared declared_logins_by_org helper if the project structure supports it.
- Around line 835-896: Update _collaborator_census to paginate all collaborator,
invitation, and organization outside-collaborator API requests using gh api
--paginate. Add a slurp/merge step such as jq -s so the multi-page output
becomes one JSON array before each json.loads call, preserving the existing
sorting and fallback behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 3dcf220a-1fa3-4a26-9286-ff2954dab48a
📒 Files selected for processing (7)
his-hand-levers.jsoninstitutio/github/access.yamlinstitutio/github/estate.yamlinstitutio/governance/parameters.yamlscripts/collab-sync.pyscripts/gitvs.pyscripts/tests/gitvs.test.sh
| def _collaborator_census(estate: dict, access: dict | None, token: str | None, online: bool) -> dict: | ||
| """Class N's Lens: outside collaborators + pending invites over the bounded probe set | ||
| (granted repos ∪ never-grant repos), plus each owner org's outside-collaborator roll. | ||
| Logins + roles only — the same public facts the ACCESS registry already carries.""" | ||
| out: dict = {"complete": False, "by_repo": {}, "org_outside": {}} | ||
| if not online or not isinstance(access, dict) or not access: | ||
| return out | ||
| grants = access.get("grants") or {} | ||
| policy = access.get("policy") or {} | ||
| probe = sorted({str(r) for r in grants} | {str(r) for r in (policy.get("never_grant_repos") or [])}) | ||
| ok = True | ||
| for repo in probe: | ||
| row: dict = {"outside": None, "invitations": None} | ||
| r = _gh( | ||
| [ | ||
| "api", | ||
| f"/repos/{repo}/collaborators?affiliation=outside&per_page=100", | ||
| "--jq", | ||
| "[.[] | {login: .login, role: .role_name}] | sort_by(.login)", | ||
| ], | ||
| token, | ||
| timeout=30, | ||
| ) | ||
| if r.returncode == 0: | ||
| try: | ||
| row["outside"] = json.loads(r.stdout or "[]") | ||
| except ValueError: | ||
| ok = False | ||
| else: | ||
| ok = False | ||
| r = _gh( | ||
| [ | ||
| "api", | ||
| f"/repos/{repo}/invitations?per_page=100", | ||
| "--jq", | ||
| "[.[] | {id: .id, login: .invitee.login, role: .permissions}] | sort_by(.login)", | ||
| ], | ||
| token, | ||
| timeout=30, | ||
| ) | ||
| if r.returncode == 0: | ||
| try: | ||
| row["invitations"] = json.loads(r.stdout or "[]") | ||
| except ValueError: | ||
| ok = False | ||
| else: | ||
| ok = False | ||
| out["by_repo"][repo] = row | ||
| for org in owners(estate): | ||
| r = _gh( | ||
| ["api", f"/orgs/{org}/outside_collaborators?per_page=100", "--jq", "[.[].login] | sort"], | ||
| token, | ||
| timeout=30, | ||
| ) | ||
| try: | ||
| # personal accounts 404 here — degrade to None; class N skips that roll, never guesses | ||
| out["org_outside"][org] = json.loads(r.stdout or "[]") if r.returncode == 0 else None | ||
| except ValueError: | ||
| out["org_outside"][org] = None | ||
| out["complete"] = ok | ||
| return out | ||
|
|
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Does gh api --paginate correctly concatenate multiple paginated JSON array responses when combined with --jq?
💡 Result:
No, gh api --paginate does not automatically concatenate multiple paginated responses into a single, valid JSON array when used with --jq [1][2][3]. When you use --paginate, gh api performs multiple HTTP requests to fetch all pages, but it outputs each page as an individual, discrete JSON document to standard output [4][1][3]. Consequently, the resulting stream is a sequence of separate JSON objects or arrays rather than one cohesive JSON array [4][5][3]. If you use --jq alongside --paginate, the jq expression is applied independently to each individual page [5]. This means you will receive a sequence of the filtered results, not a single merged result [5]. To produce a single, concatenated JSON array, you should use the --slurp flag in conjunction with --paginate [4]. Note the following limitations and usage requirements: 1. Compatibility: The --slurp flag is designed to wrap all pages of JSON arrays or objects into one outer JSON array [4]. However, it cannot be used at the same time as --jq or --template because those flags are mutually exclusive with --slurp [6]. 2. Workarounds: If you need to filter and aggregate results, the common community-recommended approach is to use gh api --paginate --jq '...' (to filter per page) and then pipe the output to jq -s (the slurp flag for the standalone jq utility) to merge the streams into one final JSON array [2][7][8]. Example: # Using pipe to jq for merging results gh api --paginate --jq '.[]' | jq -s '.' For REST API calls, recent versions of gh have improved handling to output a single JSON array by massaging the stream (omitting redundant brackets), but this behavior is distinct from the general --slurp mechanism [1]. For complex GraphQL responses, manual piping to jq -s remains the standard practice [2][8].
Citations:
- 1: api: output a single JSON array in REST pagination mode cli/cli#7190
- 2: Output a single JSON document from
api --paginatecli/cli#1268 - 3: Listing all repositories using `api` without manual pagination cli/cli#3257
- 4: https://cli.github.com/manual/gh_api
- 5: Allow
gh api--paginate --slurpto be used with--jqcli/cli#10459 - 6: https://github.com/cli/cli/blob/trunk/pkg/cmd/api/api.go
- 7: https://github.com/github/gh-cli/blob/v2.94.0/skills/gh/SKILL.md
- 8: Output a single JSON document from
api --paginatecli/cli#1268
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate gitvs.py and inspect relevant functions =="
fd -a 'gitvs\.py$' . | sed 's#^\./##'
python3 - <<'PY'
from pathlib import Path
p = next(Path('.').rglob('gitvs.py'))
text = p.read_text()
print('FILE', p)
lines = text.splitlines()
for needle in ['def _collaborator_census', 'def _gh', "_collaborator_census("]:
try:
idx = next(i for i,l in enumerate(lines,1) if needle in l)
start=max(1, idx-5); end=min(len(lines), idx+120)
print(f'\n--- {p}:{start}-{end} ({needle}) ---')
for n in range(start, end+1):
print(f'{n:4d}\t{lines[n-1]}')
except StopIteration:
print(f'not found: {needle}')
PY
echo
echo "== gh api docs behavior if available locally =="
man gh-api >/tmp/man-gh-api.txt 2>/tmp/gh-api-man.err || true
if [ -f /tmp/man-gh-api.txt ]; then
rg -n "paginate|pag|jq|slurp|JSON" /tmp/man-gh-api.txt | head -80
else
cat /tmp/gh-api-man.err || true
fi
echo
echo "== read-only behavioral probe: inspect current CLI docs from installed gh if present =="
if command -v gh >/dev/null 2>&1; then
gh --version | head -3
gh api help | sed -n '1,220p' | rg -n "paginate|pagination|JSON|jq|slurp|--jq" -C 2 || true
else
echo "gh not installed"
fiRepository: organvm/limen
Length of output: 18426
🌐 Web query:
GitHub API /repos/owner/repo/collaborators pagination per_page 100 maximum
💡 Result:
The GitHub REST API endpoint for listing collaborators supports a per_page query parameter, which has a maximum allowed value of 100 [1][2]. The default value for this parameter is 30 [1][2]. If you provide a per_page value greater than 100, the API will silently clamp the value to the maximum (100) rather than returning an error [3]. This means that requests for more than 100 items per page will still return a 200 OK status, but they will only return 100 items [3]. To retrieve more than 100 collaborators, you must paginate through the results using the page parameter or by following the URLs provided in the link header of the response [4][5][6].
Citations:
- 1: https://docs.github.com/en/rest/collaborators/collaborators
- 2: https://docs.github.com/rest/collaborators/collaborators
- 3: Document per_page max and silent clamping of oversized values github/docs#45189
- 4: https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2026-03-10
- 5: https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api
- 6: https://github.com/github/docs/blob/main/content/rest/using-the-rest-api/using-pagination-in-the-rest-api.md
🌐 Web query:
GitHub API /repos/owner/repo/invitations pagination per_page 100 maximum
💡 Result:
The GitHub REST API endpoint for listing repository invitations (GET /repos/{owner}/{repo}/invitations) supports pagination using the per_page query parameter [1][2]. The maximum value allowed for the per_page parameter is 100 [1][3]. The default value for this parameter is 30 [1][4]. You can retrieve additional pages of results by using the page query parameter or by following the pagination links provided in the response headers [5].
Citations:
- 1: https://docs.github.com/en/rest/collaborators/invitations
- 2: https://docs.github.com/en/rest/collaborators/invitations?apiVersion=2026-03-10
- 3: https://docs.github.com/en/enterprise-server@3.17/rest/collaborators/invitations?apiVersion=2022-11-28
- 4: https://docs.github.com/en/enterprise-server@3.8/rest/collaborators/invitations
- 5: https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api
🌐 Web query:
GitHub API /orgs/organization/outside_collaborators pagination per_page 100 maximum
💡 Result:
The GitHub REST API endpoint for listing an organization's outside collaborators (/orgs/{org}/outside_collaborators) supports the per_page query parameter, which allows you to specify the number of results returned per page [1][2]. The maximum value allowed for this parameter is 100 [1][3][4]. The default value is 30 [1][5][3]. To retrieve results beyond the first page, you can use the page query parameter in conjunction with per_page [1][6]. For more extensive data retrieval, it is recommended to follow the pagination links provided in the Link header of the API response, or utilize tools like the octokit.paginate method, which can automatically handle fetching subsequent pages [6][7][8].
Citations:
- 1: https://docs.github.com/en/rest/orgs/outside-collaborators?apiVersion=2026-03-10
- 2: https://docs.github.com/en/rest/orgs/outside-collaborators
- 3: https://docs.github.com/en/rest/orgs/outside-collaborators?apiVersion=2022-11-28
- 4: https://docs.github.com/en/enterprise-server@3.21/rest/orgs/outside-collaborators
- 5: https://docs.github.com/en/enterprise-cloud@latest/rest/orgs/outside-collaborators
- 6: https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api
- 7: https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2026-03-10
- 8: https://docs.github.com/en/enterprise-cloud@latest/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28
Paginate collaborator/invitation/outside-collaborator lookups and slurp the per-page JSON arrays.
per_page=100 is the maximum allowed for these GitHub REST endpoints, so >100 results are split across pages and without pagination the current code ignores later pages. With gh api --paginate, each page is emitted separately; add a slurp/merge step before json.loads (for example | jq -s .) so later pages are included in the governance check.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/gitvs.py` around lines 835 - 896, Update _collaborator_census to
paginate all collaborator, invitation, and organization outside-collaborator API
requests using gh api --paginate. Add a slurp/merge step such as jq -s so the
multi-page output becomes one JSON array before each json.loads call, preserving
the existing sorting and fallback behavior.
| # N — collaborator drift (the partner-partition rung): live outside collaborators vs the | ||
| # ACCESS registry. Undeclared access or an over-ceiling role is RED (removal is the machine- | ||
| # runnable direction, via collab-sync --apply); a declared-but-absent grant is a STAGED INVITE — | ||
| # outbound is his hand, so it is cited on the lever, never auto-sent. | ||
| access = load_access() | ||
| coll = led.get("collaborators") or {} | ||
| n_atom = "L-PARTNER-GRANTS" | ||
| if access is None: | ||
| skips.append("[N collaborator-drift] no ACCESS registry (institutio/github/access.yaml absent)") | ||
| elif not access: | ||
| pass # unparseable registry is already a class-H parity defect this run | ||
| elif not coll.get("complete"): | ||
| skips.append("[N collaborator-drift] census incomplete (gh errors)") | ||
| else: | ||
| grants = access.get("grants") or {} | ||
| declared_logins = { | ||
| str(g.get("login", "")).lower() | ||
| for rows_ in grants.values() | ||
| if isinstance(rows_, list) | ||
| for g in rows_ | ||
| if isinstance(g, dict) | ||
| } | ||
| for org, roll in sorted((coll.get("org_outside") or {}).items()): | ||
| if roll is None: | ||
| skips.append(f"[N collaborator-drift] {org}: outside-collaborator roll unreadable") | ||
| continue | ||
| undeclared = sorted(login for login in roll if str(login).lower() not in declared_logins) | ||
| if undeclared: | ||
| fails.append( | ||
| f"[N collaborator-drift] {org}: undeclared outside collaborator(s): {', '.join(undeclared)}" | ||
| ) | ||
| for repo, obs in sorted((coll.get("by_repo") or {}).items()): | ||
| declared = {str(g.get("login", "")).lower(): g for g in (grants.get(repo) or []) if isinstance(g, dict)} | ||
| outside = obs.get("outside") | ||
| if outside is None: | ||
| skips.append(f"[N collaborator-drift] {repo}: collaborator list unreadable") | ||
| continue | ||
| for c in outside: | ||
| login = str(c.get("login") or "") | ||
| role = ROLE_NAME_TO_GRANT.get(str(c.get("role")), str(c.get("role"))) | ||
| g = declared.get(login.lower()) | ||
| if g is None: | ||
| fails.append(f"[N collaborator-drift] {repo}: {login} ({role}) has no grant row") | ||
| elif GRANT_ROLE_RANK.get(role, 99) > GRANT_ROLE_RANK.get(str(g.get("role")), -1): | ||
| fails.append( | ||
| f"[N collaborator-drift] {repo}: {login} live role {role} exceeds declared {g.get('role')}" | ||
| ) | ||
| live = {str(c.get("login") or "").lower() for c in outside} | ||
| pending = {str(i.get("login") or "").lower() for i in (obs.get("invitations") or [])} | ||
| for login_l, g in sorted(declared.items()): | ||
| if login_l in live: | ||
| continue | ||
| if login_l in pending: | ||
| cites.append(f"[N collaborator-drift] {repo}: invite pending for {g.get('login')} → {n_atom}") | ||
| else: | ||
| (cites if n_atom in homed else fails).append( | ||
| f"[N collaborator-drift] {repo}: {g.get('login')} declared but absent — staged invite → " | ||
| + (f"{n_atom} (owned, open)" if n_atom in homed else f"{n_atom} (UNHOMED)") | ||
| ) | ||
|
|
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Undeclared-collaborator check is not scoped per org, letting access leak across orgs. Both the doctor() N-rung and the collab-sync.py --apply removal path build a single flat set of every declared login across all repos/orgs, then use it to judge whether a login found in one org's outside_collaborators roll is "declared." A partner granted access to a repo in Org A is therefore never flagged (or removed) when they turn up as an undeclared outside collaborator of Org B — exactly the partition guarantee L-PARTNER-GRANTS/class N is meant to enforce, and the only backstop for repos outside the bounded probe set (grants ∪ never_grant_repos).
scripts/gitvs.py#L1643-L1702: scopedeclared_loginsby the owning org (e.g.repo.split("/", 1)[0]) before comparing against each org'srollat Line 1669, instead of the flat global set built at Lines 1658-1664.scripts/collab-sync.py#L107-L115: apply the identical org-scoping fix to thedeclared_loginsset built at Lines 60-67 before it gates the--applyremoval loop fororg_outside.
Consider extracting one shared declared_logins_by_org(access) helper in gitvs.py that both files call, eliminating the duplicated (and now-diverging) computation.
📍 Affects 2 files
scripts/gitvs.py#L1643-L1702(this comment)scripts/collab-sync.py#L107-L115
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/gitvs.py` around lines 1643 - 1702, Scope declared collaborator
logins by owning organization rather than using one global set, so cross-org
grants do not suppress undeclared-collaborator findings. In scripts/gitvs.py
lines 1643-1702, update the N-rung’s declared-login computation and org_outside
comparison to use each repository’s organization; apply the identical scoping in
scripts/collab-sync.py lines 107-115 for the --apply removal path. Reuse a
shared declared_logins_by_org helper if the project structure supports it.
PR 2 of the partner-partition plan (builds on #1407's ACCESS registry).
repo_collaboratorsresource row — approval-gated delegate (L-PARTNER-GRANTS), so gitvs reconcile never auto-runs the effector._collaborator_census()(bounded probe set: granted ∪ never-grant repos viaaffiliation=outside, plus each org's outside-collaborator roll — logins/roles only) feeding doctor class N: undeclared access or an over-ceiling role is RED; a declared-but-absent grant is a staged invite CITED on the lever (outbound stays his hand).--planprints drift plus the exactgh apicommands for the operator;--applyremoves UNDECLARED access only (exposure-reducing direction), double-dark behindLIMEN_COLLAB_APPLY=1.LIMEN_COLLAB_APPLYdeclared; his-hand-levers.json:L-PARTNER-GRANTShomed.Verification:
gitvs.test.shPASS (31 checks);verify-scoped.sh→ Scoped verification passed (gitvs-parity, gitvs-test, check-params, check-gates all green).🤖 Generated with Claude Code
Summary by CodeRabbit