Context
The branch-ready first slice of the #10476 P8 anti-astroturfing work (Epic #10291). At intake, the multi-cycle review consensus on the #10476 thread narrowed P8 into a sequence of slices and asked for a "narrowed successor with a Contract Ledger" before code: ship the PURE classifier + sanitizer + fixtures first, then wire them into the read/ingest boundaries in explicitly-separated follow-up slices. This ticket is that successor — the pure, cross-service foundation. Delivered by the linked PR.
The Problem
The get_conversation read path and the KB ingestion path expose a comment's author.login but project NO trust tier today, and there is no content sanitizer to reuse. So an external, peer-toned astroturf comment — a trailing marketing URL, a link-free product-name seed, or a vendor pitch with engagement-bait + an external-endpoint offer — is both traversable by an agent (watering-hole / injection risk) and ingested verbatim into the KB corpus (SEO / name-seeding link-farm).
The Architectural Reality
ai/graph/identityRoots.mjs — TRUST_TIERS (8-tier provenance taxonomy) + the canonical IDENTITIES roster (who the swarm is). The classifier derives the known-internal set from it.
ai/services/shared/contentTrust/ (NEW) — placed per structural-pre-flight: cross-service PURE functional helpers consumed by BOTH github-workflow read paths AND KB ingestion, mirroring the ai/services/shared/vector/ precedent. A consumer-local home (github-workflow/ or knowledge-base/) would couple the two services — the boundary watchpoint raised at intake.
The Fix
Two pure, side-effect-free modules + fixtures:
authorTrustClassifier.mjs — fail-closed login → TRUST_TIERS; unknown → external, empty → unclassified; never infers trust up from prose; collaborators injected (not fetched).
astroturfSanitizer.mjs — trust-gated (only external/unclassified content): defangs URLs → [QUARANTINED_URL: <domain>], redacts injected-denylist product names, and FLAGS high-precision stealth-intent signals (engagement-bait reward-conditional, external-endpoint offer) WITHOUT redacting them (preserve technical signal). Non-mutating.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
classifyAuthorTrust(login, {collaborators}) (NEW pure fn) |
identityRoots.TRUST_TIERS + IDENTITIES roster |
login → tier; roster wins, then collaborators → repo-trusted, else external; empty → unclassified |
unknown → external (fail-closed) |
JSDoc |
fixtures green |
isExternalTier(tier) (NEW pure fn) |
this ticket |
external/unclassified → true; roster tiers → false |
n/a |
JSDoc |
fixture |
sanitizeContent(content, {tier, productNameDenylist}) (NEW pure fn) |
this ticket + the documented #10476 variants |
trust-gated URL-defang + denylist name-redact + stealth-signal flags; returns {sanitized, redactions, signals, wasModified} |
trusted tier / empty → passthrough |
JSDoc |
fixtures (both variants + trust-gate + no-false-positive) |
ai/services/shared/contentTrust/ (NEW dir) |
learn/benefits/ArchitectureOverview.md Structural Inventory |
cross-service self-defense content helpers |
n/a |
Structural Inventory row (added) |
— |
Decision Record impact
aligned-with the #10291 self-defense architecture — #10294 (P6b MCP Middleware Guards PEP) is the tool-call-boundary sibling; this is the content-boundary. No ADR conflict (0013 KB-telemetry, 0018 identity-SoT do not constrain a content-trust helper's placement).
Acceptance Criteria
Out of Scope (the later #10476 slices)
get_conversation read-boundary wiring (defang external-author payloads on the tool path).
- KB ingestion write-boundary wiring.
- The
AGENTS.md External-URL-Traversal-Prohibition rule (lands with/after the runtime boundary, never standalone prose).
- The product-name denylist content (config; this slice accepts it as a param).
Related
- Refs #10476 (the P8 tracking ticket this slices), Refs #10291 (self-defense epic), Refs #10294 (P6b MCP-boundary PEP — the tool-boundary sibling to this content-boundary).
Release classification: post-release (self-defense substrate; non-v13-blocking).
Origin Session ID: e76f14a0-87b4-4fcb-9d87-2fe65db088a9
Retrieval Hint: "content-trust author-tier classifier astroturf sanitizer defang shared/contentTrust P8 self-defense"
Context
The branch-ready first slice of the #10476 P8 anti-astroturfing work (Epic #10291). At intake, the multi-cycle review consensus on the #10476 thread narrowed P8 into a sequence of slices and asked for a "narrowed successor with a Contract Ledger" before code: ship the PURE classifier + sanitizer + fixtures first, then wire them into the read/ingest boundaries in explicitly-separated follow-up slices. This ticket is that successor — the pure, cross-service foundation. Delivered by the linked PR.
The Problem
The
get_conversationread path and the KB ingestion path expose a comment'sauthor.loginbut project NO trust tier today, and there is no content sanitizer to reuse. So an external, peer-toned astroturf comment — a trailing marketing URL, a link-free product-name seed, or a vendor pitch with engagement-bait + an external-endpoint offer — is both traversable by an agent (watering-hole / injection risk) and ingested verbatim into the KB corpus (SEO / name-seeding link-farm).The Architectural Reality
ai/graph/identityRoots.mjs—TRUST_TIERS(8-tier provenance taxonomy) + the canonicalIDENTITIESroster (who the swarm is). The classifier derives the known-internal set from it.ai/services/shared/contentTrust/(NEW) — placed perstructural-pre-flight: cross-service PURE functional helpers consumed by BOTH github-workflow read paths AND KB ingestion, mirroring theai/services/shared/vector/precedent. A consumer-local home (github-workflow/orknowledge-base/) would couple the two services — the boundary watchpoint raised at intake.The Fix
Two pure, side-effect-free modules + fixtures:
authorTrustClassifier.mjs— fail-closed login →TRUST_TIERS; unknown →external, empty →unclassified; never infers trust up from prose; collaborators injected (not fetched).astroturfSanitizer.mjs— trust-gated (only external/unclassified content): defangs URLs →[QUARANTINED_URL: <domain>], redacts injected-denylist product names, and FLAGS high-precision stealth-intent signals (engagement-bait reward-conditional, external-endpoint offer) WITHOUT redacting them (preserve technical signal). Non-mutating.Contract Ledger Matrix
classifyAuthorTrust(login, {collaborators})(NEW pure fn)identityRoots.TRUST_TIERS+IDENTITIESrosterrepo-trusted, elseexternal; empty →unclassifiedexternal(fail-closed)isExternalTier(tier)(NEW pure fn)external/unclassified→ true; roster tiers → falsesanitizeContent(content, {tier, productNameDenylist})(NEW pure fn){sanitized, redactions, signals, wasModified}ai/services/shared/contentTrust/(NEW dir)learn/benefits/ArchitectureOverview.mdStructural InventoryDecision Record impact
aligned-withthe #10291 self-defense architecture — #10294 (P6b MCP Middleware Guards PEP) is the tool-call-boundary sibling; this is the content-boundary. No ADR conflict (0013 KB-telemetry, 0018 identity-SoT do not constrain a content-trust helper's placement).Acceptance Criteria
authorTrustClassifier.mjs— fail-closed login → tier (roster / collaborator / external / unclassified);@-prefix + case agnostic.astroturfSanitizer.mjs— trust-gated URL defang + denylist name redaction + stealth-intent signal flags; non-mutating.ArchitectureOverview.mdStructural Inventory updated for the new dir.Out of Scope (the later #10476 slices)
get_conversationread-boundary wiring (defang external-author payloads on the tool path).AGENTS.mdExternal-URL-Traversal-Prohibition rule (lands with/after the runtime boundary, never standalone prose).Related
Release classification: post-release (self-defense substrate; non-v13-blocking).
Origin Session ID: e76f14a0-87b4-4fcb-9d87-2fe65db088a9 Retrieval Hint: "content-trust author-tier classifier astroturf sanitizer defang shared/contentTrust P8 self-defense"