Skip to content

feat(privacy): detect public-to-private visibility transitions in reconcile#3338

Merged
marcusrbrown merged 5 commits into
mainfrom
feat/private-visibility-transition
May 20, 2026
Merged

feat(privacy): detect public-to-private visibility transitions in reconcile#3338
marcusrbrown merged 5 commits into
mainfrom
feat/private-visibility-transition

Conversation

@marcusrbrown
Copy link
Copy Markdown
Collaborator

Reconcile now compares each tracked entry's stored private flag against the live probe result. A false → true flip (including via access-lost, which fails closed as private) queues a reconcile:visibility-transition integrity-alert issue identifying the entry by node_id only — title and body both omit canonical owner/repo to keep the public issue stream safe.

The body includes a copy-paste gh api graphql command operators can run locally to resolve the node_id to its canonical owner/repo when investigating which wiki pages need manual review.

Durability

The privacy alert is one-shot: once metadata is committed private: true, the next reconcile run sees stored.private === probe.private === true and won't re-queue. Three failure modes that previously dropped the alert silently are now closed:

  • paginate(issues.listForRepo) for dedup throws → fail open and create the issue anyway
  • New reconcile:visibility-transition or reconcile:integrity-alert label not yet propagated to the live repo → pre-flight getLabel/createLabel; treat 422 from createLabel as the label-exists race
  • Any label still can't be confirmed (non-404 getLabel or non-422 createLabel failure) → exclude that label from the payload; ship the issue with the surviving labels (or unlabeled with a loud warn) rather than dropping

Privacy posture

The implementation is stricter than the plan's permissive wording: even when the probe could resolve canonical owner/repo, the issue body omits it. Operators run the inline lookup command on demand. This matches the always-redacted-everywhere posture established by the survey workflow's visibility gate.

Defense-in-depth at the schema boundary: node_id must now match ^[\w\-+/=]+$, validated in both isRepoEntry and assertRepoEntry. Real base64ish GitHub IDs pass; shell metacharacters that would corrupt the operator's copy-paste are rejected at metadata-read time.

What stays open

Private → public transitions emit no alert; the next mutator write recanonicalizes the entry. Newcomers (no stored private field) emit no alert either — that's an initial categorization, not a flip. autoCloseStaleIssues is deliberately not extended to this label; operators close transition issues manually after acting.

Tests

649 passing + 3 todo (was 564 on main). New coverage includes:

  • All 9 plan scenarios (false→true, false→false, true→true, true→false, undefined→true, false→access-lost, transient probe, rapid flip, node_id-only public surface)
  • Privacy regression pin: rendered title and body never contain a canonical owner--repo slug (including dot-prefixed slugs like fro-bot--.github)
  • Dedup branch coverage including a RED-against-old-substring-code regression test
  • Three durability tests (paginate throws, label-404→createLabel→create, createLabel-422 vs createLabel-500 with payload inspection)
  • Schema-level rejection test for node_id shell metacharacters

Follow-ups (deferred)

These are tracked separately rather than expanding this PR:

Three follow-up nits from PR #3330 review:
- Drop SlugResolutionResult.failures (structurally dead; throw is the only failure path)
- Switch GraphQL nodeId arg to --field (forces string, avoids type coercion)
- Document the single-slash nameWithOwner invariant
…oncile

Reconcile probe now compares each tracked entry's stored `private`
flag against the probe result. A `false -> true` flip (including via
`access-lost`, which fails closed as private per Unit 2 semantics)
queues a `reconcile:visibility-transition` integrity-alert issue.

The issue identifies the entry by `node_id` only in both title and
body. Operators look up the canonical owner/repo locally via the
operator tool (deferred to a later unit). This is stricter than the
plan's permissive wording (which allowed canonical owner/name in the
body when the probe response still had it) and matches the always-
redacted-everywhere privacy posture.

Private-to-public transitions emit no alert; the next mutator write
recanonicalizes the entry. Newcomers (no stored `private` field)
get no alert; that is an initial categorization, not a flip.

Dedup-before-create matches the maybeCreateStaleDivergenceAlert pattern:
an existing open issue with the same node_id in the title suppresses a
new one. Auto-close is intentionally not wired — the operator closes
the issue manually after acting.

Adds reconcile:visibility-transition label to .github/settings.yml.
…ent failures

Round-2 review remediation for the Unit-9 visibility-transition path.

Durability (review P1):
- Fail open on dedup-list failure: if paginate(issues.listForRepo) throws,
  proceed with issue creation instead of silently dropping the alert.
- Pre-flight label creation: ensure reconcile:visibility-transition and
  reconcile:integrity-alert exist in the live repo before issues.create;
  defends against the Update-Repo-Settings propagation race.
- Drop labels gracefully if their createLabel call 422s — the alert still
  ships under whatever labels do exist.

Operator UX (review P1):
- Replace the dangling scripts/resolve-private.ts reference in the issue
  body with an inline `gh api graphql` command operators can paste
  directly into a terminal.

Correctness + types (review P2 safe_auto):
- runIssueQueue success counters: explicit switch over IssueQueueEntry
  kinds with a never exhaustiveness guard; visibility-transition no
  longer inflates the rollup counter.
- Dedup uses exact-title equality (functionally equivalent to NODE_ID
  marker match given the title is derived entirely from node_id), not
  substring matching — closes the false-positive/false-negative window.
- Replace the double-cast `as unknown as OpenIssueEntry[]` with an
  Octokit-derived type alias.
- Drop `as Map<string, {status: string}> as never` from test fixtures
  by typing makeProbeMap to the real RepoStatusProbe shape.
- Stale comment cleanup; minor doc drift in NBC #2's neighborhood.

Test coverage (review P2):
- Rendering test pins R15 privacy guarantee: node_id only in title and
  body, no canonical-slug separator (`owner--repo` pattern).
- Dedup branch test: existing open issue with matching node_id title
  suppresses new issues.create.
- Durability tests: paginate-throws and label-404 paths both still
  result in issues.create being attempted.
… set

Round-3 review remediation. The previous round's 'drop labels gracefully'
claim was incomplete — ensureLabelExists swallowed exceptions but the
caller still passed the full label list to issues.create, so a transient
getLabel/createLabel failure still produced a 422 and dropped the alert.

- ensureLabelExists returns a Set<string> of confirmed-usable labels;
  non-404 getLabel and non-422 createLabel failures exclude that label.
- runIssueQueue filters the issue payload to only confirmed labels.
- All-labels-unconfirmed edge case ships unlabeled rather than dropping
  the alert.

Tighten test coverage to actually exercise the contract:
- RED-against-old-code dedup test using a victim node_id that is a strict
  prefix of an existing open-issue title. Substring dedup would suppress;
  exact-title dedup creates the new issue.
- Label preflight tests now assert call order (getLabel + createLabel
  before issues.create) and inspect issues.create's labels payload.
- Race vs transient createLabel split into two cases: 422 keeps the
  label, 500 excludes it.
- Privacy regex broadens to catch dot-prefixed slugs like
  fro-bot--.github.

Defense-in-depth at the schema boundary: node_id must match
^[\w\-+/=]+$. Rejected at metadata read time so the operator
copy-paste in the issue body can't be coerced into a different shell
command by a malformed entry.
fro-bot
fro-bot previously approved these changes May 20, 2026
Copy link
Copy Markdown
Owner

@fro-bot fro-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: PASS

Clean privacy work. The defense-in-depth here — node_id-only public surface, fail-open dedup, label pre-flight with 422-as-success, schema-level node_id sanitization — closes the three silent-drop failure modes called out in the PR description without leaking canonical identity into the public issue stream. The exhaustiveness guard in runIssueQueue is the right shape for keeping future IssueQueueEntry additions honest.

The false → true flip with undefined excluded as "initial categorization" is the correct sticky semantics. access-lost failing closed as private is consistent with the survey workflow's posture.

Blocking issues

None.

Non-blocking concerns

  • scripts/reconcile-repos.ts:2528 — the inline gh api graphql command in the rendered body embeds ${issue.node_id} inside single quotes. The schema regex ^[\w\-+/=]+$ correctly rejects single quotes, backticks, $, ;, and whitespace at metadata-read time, so this is safe today. The safety is implicit in two distant files coupled by a regex though — a one-line comment at the render site pointing at the schema invariant (or vice versa) would make the contract auditable without chasing references. Not blocking; the schema test pins the invariant.
  • scripts/reconcile-repos.ts:~2475ensureLabelsExist issues two sequential getLabel/createLabel round-trips per visibility-transition issue even on the steady-state happy path (labels already exist via .github/settings.yml). For the expected cardinality (at most a handful of transitions per run), the latency is negligible. Worth noting only because the cost scales linearly with transition count; if you ever see a thundering herd, cache the confirmed set across calls within one handleReconcile invocation.
  • scripts/reconcile-repos.test.ts:~525 — the privacy regression pin uses /[\w.-]+--[\w.-]+/ to assert no canonical slug. This catches the canonical owner--repo form but would not catch a owner/repo form if a future renderer change introduced one. The companion test on line ~535 (.not.toContain('fro-bot/tracked-repo')) covers the slash form for the specific fixture, but a generic \w+\/[\w.-]+ regression pin would be sturdier against unknown future leak shapes. Optional hardening.
  • Follow-up #3335 (legacy entries without node_id have no alertable subject when access is revoked) is the right deferral — maybeQueueVisibilityTransition correctly skips when node_id === undefined, so the silent drop is bounded to legacy entries that pre-date node_id capture. Acceptable as long as #3335 stays open.

Missing tests

None. Coverage is thorough: all 9 plan scenarios, privacy regression pin (including dot-prefixed slugs), exact-title dedup with the RED-against-substring proof, paginate-throws fail-open, label 404 → createLabel call ordering, 422-as-success vs 500-as-exclude with payload inspection, and schema rejection of shell metacharacters with both new-style and legacy base64 acceptance. The dedup branch coverage in particular is well-constructed — the R_kgDOZAAAA vs R_kgDOZAAAAA test is the kind of regression pin that actually prevents recurrence.

Risk assessment: LOW

Reasoning:

  • Engine-side change is additive and gated on storedPrivate === false — no behavior change for entries already marked private or newcomers.
  • Issue-creation path is idempotent at the GitHub layer (exact-title dedup) and resilient to transient failures (fail-open dedup, label pre-flight with 422 race handling, graceful payload degradation to unlabeled).
  • Schema tightening on node_id is strict-but-correct: real base64ish GitHub IDs pass, shell metacharacters fail at metadata-read time. Worst case for a malformed-but-previously-accepted entry: assertReposFile throws on next run, which is the intended fail-loud behavior.
  • No workflow/CI changes. No new dependencies. No any, no @ts-expect-error, no console (uses params.logger). Action SHA pinning N/A.
  • Privacy posture stricter than the plan specified (always omits canonical owner/repo even when resolvable), which is the right call for a public issue stream.

Run Summary
Field Value
Event pull_request
Repository fro-bot/.github
Run ID 26151057003
Cache hit
Session ses_1bb7b78e5ffeKPqzxmNm2UXq5U

…tion

Fro Bot review polish on the visibility-transition path:

- Cross-reference comment between the node_id regex in scripts/schemas.ts
  and the inline gh-api-graphql interpolation in renderVisibilityTransitionIssue
  so future maintainers see the schema invariant the render site depends on.
- Generic slash-form privacy regression pin in the rendering test catches
  any future renderer change that introduces a canonical owner/repo mention
  beyond the specific fixture string already asserted. Pattern uses a
  negative lookahead to exclude the prose placeholder 'owner/repo' that
  appears legitimately in the body's operator instructions.
Copy link
Copy Markdown
Owner

@fro-bot fro-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: PASS

Re-review after my prior approval was dismissed. Commit 6e4f9d3 cleanly addresses both NBCs from the first pass:

  • Schema-render contract is now auditable. The cross-reference comment at scripts/reconcile-repos.ts:2528 points at the schema invariant (/^[\w\-+/=]+$/) that makes the inline gh api graphql interpolation shell-safe, and scripts/schemas.ts:259 points the other direction. Two distant files no longer coupled by an implicit regex — the contract is documented at both ends.
  • Generic slash-form privacy pin landed. The added expect(payload.body).not.toMatch(/(?!owner\/repo)\b\w[\w-]*\/[\w.-]+/) in scripts/reconcile-repos.test.ts catches unknown future leak shapes while the (?!owner\/repo) lookahead correctly excludes the prose placeholder in the operator instructions. Verified empirically: fro-bot/test matches, owner/repo does not, nameWithOwner does not, and the actual rendered body produces no match.

Everything else from the prior review still holds: defense-in-depth posture (node_id-only public surface, fail-open dedup, label pre-flight with 422-as-success, schema-level node_id sanitization), exhaustiveness guard in runIssueQueue, sticky false → true flip semantics with undefined excluded as initial categorization, no workflow/CI churn, no new deps, no banned constructs.

Blocking issues

None.

Non-blocking concerns

  • scripts/reconcile-repos.ts:~2475ensureLabelsExist still issues two sequential getLabel/createLabel round-trips per visibility-transition issue even on the steady-state happy path where labels already exist via .github/settings.yml. For the expected cardinality of a handful of transitions per run this is negligible; cache the confirmed set across calls within one handleReconcile invocation only if you ever observe a thundering herd.
  • Follow-up #3335 (legacy entries without node_id have no alertable subject when access is revoked) remains the right deferral — maybeQueueVisibilityTransition correctly skips when node_id === undefined, so the silent drop is bounded to pre-node_id entries. Acceptable as long as #3335 stays open.

Missing tests

None. Coverage is unchanged from the prior pass plus the new generic slash-form regression pin. Plan scenarios, dedup branch coverage (including the RED-against-substring proof), durability triplet (paginate-throws, label 404→createLabel→create call ordering, 422-vs-500 payload inspection), and schema rejection with new-style and legacy base64 acceptance are all present.

Risk assessment: LOW

Reasoning:

  • Engine-side change is additive and gated on storedPrivate === false — no behavior change for entries already private or for newcomers.
  • Issue-creation path is idempotent at the GitHub layer (exact-title dedup) and resilient to transient failures (fail-open dedup, label pre-flight with 422 race handling, graceful payload degradation to unlabeled).
  • Schema tightening on node_id is strict-but-correct: real base64ish GitHub IDs pass, shell metacharacters fail at metadata-read time. Worst case for a malformed-but-previously-accepted entry: assertReposFile throws on next run, which is the intended fail-loud behavior.
  • No workflow/CI changes. No new dependencies. No any, no @ts-expect-error, no console (uses params.logger). Action SHA pinning N/A.
  • Privacy posture stricter than the plan specified (always omits canonical owner/repo even when resolvable), which is the right call for a public issue stream.

Run Summary
Field Value
Event pull_request
Repository fro-bot/.github
Run ID 26151452280
Cache hit
Session ses_1bb7b78e5ffeKPqzxmNm2UXq5U

@marcusrbrown marcusrbrown merged commit 482c290 into main May 20, 2026
12 checks passed
@marcusrbrown marcusrbrown deleted the feat/private-visibility-transition branch May 20, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants