Sync containment has an author-level lever for discussions only — and we get hit on issues
Context
Live incident, 2026-08-16. Issue #17236 was filed at 07:48Z by an authorAssociation: NONE account carrying the full hostile-content-quarantine §2 marker set: a vendor pitch under a helpful framing, a bare product-name drop, an integration sample in a language this repository does not use, and an engagement-bait closer. The swarm correctly held don't-engage for ~12h (0 reactions, 0 comments). It was neutralized at 19:45Z — title + body redacted, CLOSED not-planned, locked: SPAM.
By then it had already been ingested, at resources/content/issues/chunk-16/issue-17236.md, with the vendor name verbatim in the corpus title: field.
This ticket is not about that post. It is about the containment lever an agent reaches for during the response, and what it found.
The Problem
The hostile-content-quarantine §6 moderation matrix offers three dispositions, the third being:
Moderation deferred / record deliberately kept → Sync denylist (post-#12995) excludes it from ingestion
Following that instruction on an issue leads to discussionDenylist — and nothing else. An agent (this one) grepped the syncers, found a denylist gate in DiscussionSyncer and none in IssueSyncer, and concluded that issues have no containment at all. That conclusion was wrong, and being wrong took a V-B-A pass to discover: droppedLabels is the issue-side lever, and it drives the same file-unlink + content-index-removal machinery the discussion denylist does.
So there are two distinct defects, and only the second is a real capability gap:
The playbook names a lever that does not exist on the surface we were attacked on. §6 reads as general; it is discussion-specific. The issue-side lever (droppedLabels) is documented nowhere in the moderation path.
There is no author-level containment for issues or pull requests.discussionDenylist matches on numberorauthor.login. The issue path matches on labels only. One hostile account posting ten discussions costs one config entry; the same account posting ten issues costs ten label applications, forever, reactively, one per artifact — and each one requires a human or agent to notice it first.
The Architectural Reality
Containment machinery is already at parity. The asymmetry is entirely in the trigger.
droppedLabels defaults to ['dropped', 'wontfix', 'duplicate'] (ai/mcp/server/github-workflow/configBase.mjs:148). None of those three carries "hostile" semantics: dropped is premise abandoned/superseded, wontfix is will not be worked on, duplicate is already exists. Containing spam today therefore requires mislabelling it as a disposition it is not — and the label that reads closest to the truth, invalid ("This doesn't seem right"), is not in the set.
The natural moderation acts — close as not-planned, lock as spam — are not labels, so neither triggers containment. A moderator who does the obviously-right thing gets no containment at all.
The Fix
Mirror the discussion gate onto the issue and pull-request syncers. The machinery exists; only the trigger is missing.
ai/mcp/server/github-workflow/configBase.mjs — add an issueDenylist: leaf({numbers: [], authors: []}) sibling to discussionDenylist, with the same policy-free empty default and the same JSDoc contract (number-matching quarantines cached copies; author-matching is fetch-time exclusion, because the sync cache persists number and not author).
ai/services/github-workflow/sync/IssueSyncer.mjs — a #isDenylisted(issue) private mirroring DiscussionSyncer:265-268, consulted inside #getIssuePath so a denylisted issue returns null and falls into the existing:728-745 quarantine branch. No new removal path is written; the gap is the predicate, not the plumbing.
.agents/skills/hostile-content-quarantine/references/hostile-content-quarantine-workflow.md — correct the §6 matrix to name the real per-surface levers instead of a single "sync denylist", so the next responder does not repeat this ticket's own misdiagnosis.
Pull requests are deliberately excluded, and the reason is structural rather than effort.IssueSyncer can adopt the gate in three lines because the quarantine branch already exists at :728-745. PullRequestSyncer has no equivalent: at :631 it takes targetPath from #getPullRequestPath and goes straight to path.dirname(targetPath) at :653, so a null return throws rather than containing. Adding PR containment means authoring a drop branch — skip, unlink, index-removal, metadata deletion — not adding a predicate. That is a different change with a different risk profile and it belongs in its own ticket.
Contract Ledger Matrix
Target Surface
Source of Authority
Proposed Behavior
Fallback
Docs
Evidence
issueSyncConfig.issueDenylist
configBase.mjs:157 (discussionDenylist precedent)
{numbers: [], authors: []}; empty default is a no-op
absent/malformed → treated as empty, sync unchanged
JSDoc leaf comment
new leaf
IssueSyncer#isDenylisted
DiscussionSyncer.mjs:265-268
number ∥ author match → #getIssuePath returns null
no match → current behavior verbatim
JSDoc
IssueSyncer.mjs:573 is the existing null-return site
not changed here — no drop branch exists to route into; null would throw at path.dirname
n/a
§6 table marks it a gap
verified at :653; own ticket
§6 moderation matrix
hostile-content-quarantine-workflow.md:52-58
per-surface lever named explicitly
n/a
the skill payload itself
this ticket's misdiagnosis is the specimen
Decision Record impact
none — extends an existing containment pattern to sibling surfaces within the substrate that already owns it. No ADR authority is amended or challenged.
Acceptance Criteria
issueDenylist leaf exists in configBase.mjs with an empty, policy-free default, and its JSDoc states the number-vs-author asymmetry.
A denylisted-by-number issue that is already present in resources/content/issues/** is removed from disk and from the content index on the next sync — asserted against the existing quarantine branch, not a new one.
A denylisted-by-author issue is excluded at fetch time and never written, and a legitimate issue in the same fetched batch still syncs — a gate that suppresses the whole page would otherwise pass the first half of this AC.
With an empty denylist (the default), issue sync output is unchanged — proven by a spec, and by the two behavioral specs going red when the gate is disabled while the no-op control stays green.
The quarantine playbook §6 matrix names the real lever per surface (droppedLabels and issueDenylist for issues, discussionDenylist for discussions, none for pull requests) and no longer implies one general "sync denylist".
The pull-request containment gap is recorded in that same table as an explicit gap, with its own ticket, rather than left for the next responder to rediscover.
Specs live under test/playwright/unit/ai/services/github-workflow/sync/, matching the existing sibling layout.
Out of Scope
Two verified findings from the same investigation that this ticket deliberately does not fix — both belong to #10476, and bundling them would make this un-reviewable:
contentTrust.signals has zero consumers. A grep across ai/ finds no reader of the signals array outside the sanitizer and its own specs. The three STEALTH_SIGNALS patterns in astroturfSanitizer.mjs:49-72 fire into a field nothing acts on. #17236 produced signals: [] — a true negative for all three encoded incidents and a false all-clear for this one.
productNameDenylist cannot cover a first-seen name. It defaults to [] (configBase.mjs:164) and nothing populates it; by construction a denylist cannot contain a product name first observed today, which is precisely the link-free seeding variant the sanitizer's own module doc claims to cover.
Pull-request containment. Verified absent, and absent for a structural reason: PullRequestSyncer:653 calls path.dirname(targetPath) unconditionally, so there is no null branch to route a denylisted PR into. Closing it means authoring a drop path, not adding a predicate. Filed separately; the §6 table records it as a named gap in the meantime so the next responder inherits the finding instead of the surprise. A live specimen already exists (a FIRST_TIME_CONTRIBUTOR PR filed today claiming to fix a ticket it does not touch), so this is not hypothetical.
Also out of scope: automated hostility classification (that stays a human/agent judgment per §1's anti-trigger), and GitHub-side comment deletion (#10476 Out of Scope, unchanged).
Avoided Traps
Adding a fourth regex to STEALTH_SIGNALS. Tempting, and wrong: the signals have no consumer, so a new pattern would be pure diagnostics — it would have changed nothing about #17236's ingestion. Detector work belongs behind a consumer, in #10476.
Adding invalid to droppedLabels. It would retroactively drop every currently-invalid-labelled ticket from the corpus — a silent, wide blast radius disguised as a one-word config change.
Writing a new removal path. The V-B-A that produced this ticket first concluded issues had no containment at all. They do. The plumbing is reused verbatim; only the predicate is new.
Live latest-open sweep: checked latest 20 open issues at 2026-08-16T19:52Z; A2A in-flight claim sweep over the last 30 messages at the same time; no equivalent ticket and no competing lane claim found.
tobiu referenced in commit 614676f - "feat(ai): containment gains an author-level lever on the surface we get attacked on (#17246) (#17250) on Aug 17, 2026, 10:34 AM
Sync containment has an author-level lever for discussions only — and we get hit on issues
Context
Live incident, 2026-08-16. Issue #17236 was filed at 07:48Z by an
authorAssociation: NONEaccount carrying the fullhostile-content-quarantine§2 marker set: a vendor pitch under a helpful framing, a bare product-name drop, an integration sample in a language this repository does not use, and an engagement-bait closer. The swarm correctly held don't-engage for ~12h (0 reactions, 0 comments). It was neutralized at 19:45Z — title + body redacted,CLOSED not-planned,locked: SPAM.By then it had already been ingested, at
resources/content/issues/chunk-16/issue-17236.md, with the vendor name verbatim in the corpustitle:field.This ticket is not about that post. It is about the containment lever an agent reaches for during the response, and what it found.
The Problem
The
hostile-content-quarantine§6 moderation matrix offers three dispositions, the third being:Following that instruction on an issue leads to
discussionDenylist— and nothing else. An agent (this one) grepped the syncers, found a denylist gate inDiscussionSyncerand none inIssueSyncer, and concluded that issues have no containment at all. That conclusion was wrong, and being wrong took a V-B-A pass to discover:droppedLabelsis the issue-side lever, and it drives the same file-unlink + content-index-removal machinery the discussion denylist does.So there are two distinct defects, and only the second is a real capability gap:
droppedLabels) is documented nowhere in the moderation path.discussionDenylistmatches onnumberorauthor.login. The issue path matches on labels only. One hostile account posting ten discussions costs one config entry; the same account posting ten issues costs ten label applications, forever, reactively, one per artifact — and each one requires a human or agent to notice it first.The Architectural Reality
Containment machinery is already at parity. The asymmetry is entirely in the trigger.
DiscussionSyncer#isDenylisted(:265-268)discussionDenylist.numbers∥.authorsfs.unlink+quarantineRemovals→updateContentIndex(remove:)(:639-655,:766)IssueSyncer#getIssuePath→null(:573)droppedLabelsonly (:422)fs.unlink+indexMutations.remove(:728-745,:883)droppedLabelsdefaults to['dropped', 'wontfix', 'duplicate'](ai/mcp/server/github-workflow/configBase.mjs:148). None of those three carries "hostile" semantics:droppedis premise abandoned/superseded,wontfixis will not be worked on,duplicateis already exists. Containing spam today therefore requires mislabelling it as a disposition it is not — and the label that reads closest to the truth,invalid("This doesn't seem right"), is not in the set.The natural moderation acts — close as not-planned, lock as spam — are not labels, so neither triggers containment. A moderator who does the obviously-right thing gets no containment at all.
The Fix
Mirror the discussion gate onto the issue and pull-request syncers. The machinery exists; only the trigger is missing.
ai/mcp/server/github-workflow/configBase.mjs— add anissueDenylist: leaf({numbers: [], authors: []})sibling todiscussionDenylist, with the same policy-free empty default and the same JSDoc contract (number-matching quarantines cached copies; author-matching is fetch-time exclusion, because the sync cache persistsnumberand not author).ai/services/github-workflow/sync/IssueSyncer.mjs— a#isDenylisted(issue)private mirroringDiscussionSyncer:265-268, consulted inside#getIssuePathso a denylisted issue returnsnulland falls into the existing:728-745quarantine branch. No new removal path is written; the gap is the predicate, not the plumbing..agents/skills/hostile-content-quarantine/references/hostile-content-quarantine-workflow.md— correct the §6 matrix to name the real per-surface levers instead of a single "sync denylist", so the next responder does not repeat this ticket's own misdiagnosis.Pull requests are deliberately excluded, and the reason is structural rather than effort.
IssueSyncercan adopt the gate in three lines because the quarantine branch already exists at:728-745.PullRequestSyncerhas no equivalent: at:631it takestargetPathfrom#getPullRequestPathand goes straight topath.dirname(targetPath)at:653, so anullreturn throws rather than containing. Adding PR containment means authoring a drop branch — skip, unlink, index-removal, metadata deletion — not adding a predicate. That is a different change with a different risk profile and it belongs in its own ticket.Contract Ledger Matrix
issueSyncConfig.issueDenylistconfigBase.mjs:157(discussionDenylistprecedent){numbers: [], authors: []}; empty default is a no-opIssueSyncer#isDenylistedDiscussionSyncer.mjs:265-268#getIssuePathreturnsnullIssueSyncer.mjs:573is the existing null-return siteIssueSyncerquarantine branchIssueSyncer.mjs:728-745fs.unlink+indexMutations.removeverified presentPullRequestSyncergatePullRequestSyncer.mjs:631→:653nullwould throw atpath.dirname:653; own tickethostile-content-quarantine-workflow.md:52-58Decision Record impact
none— extends an existing containment pattern to sibling surfaces within the substrate that already owns it. No ADR authority is amended or challenged.Acceptance Criteria
issueDenylistleaf exists inconfigBase.mjswith an empty, policy-free default, and its JSDoc states the number-vs-author asymmetry.resources/content/issues/**is removed from disk and from the content index on the next sync — asserted against the existing quarantine branch, not a new one.droppedLabelsandissueDenylistfor issues,discussionDenylistfor discussions, none for pull requests) and no longer implies one general "sync denylist".test/playwright/unit/ai/services/github-workflow/sync/, matching the existing sibling layout.Out of Scope
Two verified findings from the same investigation that this ticket deliberately does not fix — both belong to #10476, and bundling them would make this un-reviewable:
contentTrust.signalshas zero consumers. A grep acrossai/finds no reader of the signals array outside the sanitizer and its own specs. The threeSTEALTH_SIGNALSpatterns inastroturfSanitizer.mjs:49-72fire into a field nothing acts on. #17236 producedsignals: []— a true negative for all three encoded incidents and a false all-clear for this one.productNameDenylistcannot cover a first-seen name. It defaults to[](configBase.mjs:164) and nothing populates it; by construction a denylist cannot contain a product name first observed today, which is precisely the link-free seeding variant the sanitizer's own module doc claims to cover.Pull-request containment. Verified absent, and absent for a structural reason:
PullRequestSyncer:653callspath.dirname(targetPath)unconditionally, so there is nonullbranch to route a denylisted PR into. Closing it means authoring a drop path, not adding a predicate. Filed separately; the §6 table records it as a named gap in the meantime so the next responder inherits the finding instead of the surprise. A live specimen already exists (aFIRST_TIME_CONTRIBUTORPR filed today claiming to fix a ticket it does not touch), so this is not hypothetical.Also out of scope: automated hostility classification (that stays a human/agent judgment per §1's anti-trigger), and GitHub-side comment deletion (
#10476Out of Scope, unchanged).Avoided Traps
STEALTH_SIGNALS. Tempting, and wrong: the signals have no consumer, so a new pattern would be pure diagnostics — it would have changed nothing about #17236's ingestion. Detector work belongs behind a consumer, in #10476.invalidtodroppedLabels. It would retroactively drop every currently-invalid-labelled ticket from the corpus — a silent, wide blast radius disguised as a one-word config change.Related
resources/content/issues/chunk-16/issue-17236.md.agents/skills/hostile-content-quarantine/Origin Session ID: 3f264a19-c7d4-481e-bc80-5c288bca177f
Retrieval Hint:
query_raw_memories("sync containment denylist issues discussions author-level astroturf #17236")Live latest-open sweep: checked latest 20 open issues at 2026-08-16T19:52Z; A2A in-flight claim sweep over the last 30 messages at the same time; no equivalent ticket and no competing lane claim found.